From 9933fd5ddf0bfd1dbf45f0bb7a48861bfc1d4894 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 04:44:52 +0000 Subject: [PATCH 1/9] Initial plan From 85172bcc769324edbb2f5e4f96093511b4108b1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 05:14:40 +0000 Subject: [PATCH 2/9] Sanitize control characters in console formatter output Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../Microsoft.Extensions.Logging.Console.cs | 1 + .../src/ConsoleControlCharacterSanitizer.cs | 63 +++++++++++++++++++ .../src/ConsoleFormatterOptions.cs | 8 +++ .../src/JsonConsoleFormatter.cs | 28 +++++---- .../src/SimpleConsoleFormatter.cs | 12 +++- .../src/SystemdConsoleFormatter.cs | 12 +++- .../ConsoleFormatterTests.cs | 61 ++++++++++++++++++ .../ConsoleLoggerConfigureOptions.cs | 7 ++- .../ConsoleLoggerTest.cs | 18 ++---- .../JsonConsoleFormatterTests.cs | 6 +- 10 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs b/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs index 76c9169cf61fa6..a26ba823eae0ed 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs @@ -77,6 +77,7 @@ public partial class ConsoleFormatterOptions { public ConsoleFormatterOptions() { } public bool IncludeScopes { get { throw null; } set { } } + public bool SanitizeControlCharacters { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.StringSyntaxAttribute("DateTimeFormat")] public string? TimestampFormat { get { throw null; } set { } } public bool UseUtcTimestamp { get { throw null; } set { } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs new file mode 100644 index 00000000000000..2d9e84b2ff2641 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; + +namespace Microsoft.Extensions.Logging.Console +{ + internal static class ConsoleControlCharacterSanitizer + { + public static string? Sanitize(string? value, bool sanitizeControlCharacters) + { + if (!sanitizeControlCharacters || string.IsNullOrEmpty(value)) + { + return value; + } + + int firstEscapedCharacterIndex = GetFirstEscapedCharacterIndex(value); + if (firstEscapedCharacterIndex < 0) + { + return value; + } + + var sanitized = new StringBuilder(value.Length + 8); + sanitized.Append(value, 0, firstEscapedCharacterIndex); + + for (int i = firstEscapedCharacterIndex; i < value.Length; i++) + { + char current = value[i]; + if (ShouldEscape(current)) + { + sanitized.Append(@"\u"); + sanitized.Append(((int)current).ToString("X4", CultureInfo.InvariantCulture)); + } + else + { + sanitized.Append(current); + } + } + + return sanitized.ToString(); + } + + private static int GetFirstEscapedCharacterIndex(string value) + { + for (int i = 0; i < value.Length; i++) + { + if (ShouldEscape(value[i])) + { + return i; + } + } + + return -1; + } + + private static bool ShouldEscape(char c) + { + UnicodeCategory category = char.GetUnicodeCategory(c); + return category == UnicodeCategory.Control || category == UnicodeCategory.Format; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs index e97c3cbe0c88f5..671968bd74e34a 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs @@ -41,6 +41,14 @@ public ConsoleFormatterOptions() { } /// public bool UseUtcTimestamp { get; set; } + /// + /// Gets or sets a value that indicates whether control and formatting characters are escaped in log output. + /// + /// + /// The default is . + /// + public bool SanitizeControlCharacters { get; set; } = true; + internal virtual void Configure(IConfiguration configuration) => configuration.Bind(this); } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs index 040a4d09ae7fb7..f19f49026455ba 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs @@ -56,6 +56,12 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex string category, int eventId, string? exception, bool hasState, string? stateMessage, IReadOnlyList>? stateProperties, DateTimeOffset stamp) { + bool sanitizeControlCharacters = FormatterOptions.SanitizeControlCharacters; + message = ConsoleControlCharacterSanitizer.Sanitize(message, sanitizeControlCharacters); + category = ConsoleControlCharacterSanitizer.Sanitize(category, sanitizeControlCharacters)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception, sanitizeControlCharacters); + stateMessage = ConsoleControlCharacterSanitizer.Sanitize(stateMessage, sanitizeControlCharacters); + const int DefaultBufferSize = 1024; using (var output = new PooledByteBufferWriter(DefaultBufferSize)) { @@ -65,7 +71,7 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex var timestampFormat = FormatterOptions.TimestampFormat; if (timestampFormat != null) { - writer.WriteString("Timestamp", stamp.ToString(timestampFormat)); + writer.WriteString("Timestamp", ConsoleControlCharacterSanitizer.Sanitize(stamp.ToString(timestampFormat), sanitizeControlCharacters)); } writer.WriteNumber(nameof(LogEntry.EventId), eventId); writer.WriteString(nameof(LogEntry.LogLevel), GetLogLevelString(logLevel)); @@ -91,12 +97,12 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex { foreach (KeyValuePair item in stateProperties) { - WriteItem(writer, item); + WriteItem(writer, item, sanitizeControlCharacters); } } writer.WriteEndObject(); } - WriteScopeInformation(writer, scopeProvider); + WriteScopeInformation(writer, scopeProvider, sanitizeControlCharacters); writer.WriteEndObject(); writer.Flush(); } @@ -130,7 +136,7 @@ private static string GetLogLevelString(LogLevel logLevel) }; } - private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider? scopeProvider) + private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider? scopeProvider, bool sanitizeControlCharacters) { if (FormatterOptions.IncludeScopes && scopeProvider != null) { @@ -140,25 +146,25 @@ private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider if (scope is IEnumerable> scopeItems) { state.WriteStartObject(); - state.WriteString("Message", scope.ToString()); + state.WriteString("Message", ConsoleControlCharacterSanitizer.Sanitize(scope.ToString(), sanitizeControlCharacters)); foreach (KeyValuePair item in scopeItems) { - WriteItem(state, item); + WriteItem(state, item, sanitizeControlCharacters); } state.WriteEndObject(); } else { - state.WriteStringValue(ToInvariantString(scope)); + state.WriteStringValue(ConsoleControlCharacterSanitizer.Sanitize(ToInvariantString(scope), sanitizeControlCharacters)); } }, writer); writer.WriteEndArray(); } } - private static void WriteItem(Utf8JsonWriter writer, KeyValuePair item) + private static void WriteItem(Utf8JsonWriter writer, KeyValuePair item, bool sanitizeControlCharacters) { - var key = item.Key; + string key = ConsoleControlCharacterSanitizer.Sanitize(item.Key, sanitizeControlCharacters)!; switch (item.Value) { case bool boolValue: @@ -171,7 +177,7 @@ private static void WriteItem(Utf8JsonWriter writer, KeyValuePair(in LogEntry logEntry, IExternalScopeP private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter textWriter, string message, LogLevel logLevel, int eventId, string? exception, string category, DateTimeOffset stamp) { + bool sanitizeControlCharacters = FormatterOptions.SanitizeControlCharacters; + message = ConsoleControlCharacterSanitizer.Sanitize(message, sanitizeControlCharacters)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception, sanitizeControlCharacters); + category = ConsoleControlCharacterSanitizer.Sanitize(category, sanitizeControlCharacters)!; + ConsoleColors logLevelColors = GetLogLevelConsoleColors(logLevel); string logLevelString = GetLogLevelString(logLevel); @@ -112,7 +117,7 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex } // scope information - WriteScopeInformation(textWriter, scopeProvider, singleLine); + WriteScopeInformation(textWriter, scopeProvider, singleLine, sanitizeControlCharacters); WriteMessage(textWriter, message, singleLine); // Example: @@ -198,7 +203,7 @@ private ConsoleColors GetLogLevelConsoleColors(LogLevel logLevel) }; } - private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool singleLine) + private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool singleLine, bool sanitizeControlCharacters) { if (FormatterOptions.IncludeScopes && scopeProvider != null) { @@ -215,7 +220,8 @@ private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider { state.Write(" => "); } - state.Write(scope); + string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString(), sanitizeControlCharacters); + state.Write(scopeMessage); }, textWriter); if (!paddingNeeded && !singleLine) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs index 0df001a0267c75..6536b99c3f2865 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs @@ -58,6 +58,11 @@ public override void Write(in LogEntry logEntry, IExternalScopeP private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter textWriter, string message, LogLevel logLevel, string category, int eventId, string? exception, DateTimeOffset stamp) { + bool sanitizeControlCharacters = FormatterOptions.SanitizeControlCharacters; + message = ConsoleControlCharacterSanitizer.Sanitize(message, sanitizeControlCharacters)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception, sanitizeControlCharacters); + category = ConsoleControlCharacterSanitizer.Sanitize(category, sanitizeControlCharacters)!; + // systemd reads messages from standard out line-by-line in a 'message' format. // newline characters are treated as message delimiters, so we must replace them. // Messages longer than the journal LineMax setting (default: 48KB) are cropped. @@ -82,7 +87,7 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex textWriter.Write(']'); // scope information - WriteScopeInformation(textWriter, scopeProvider); + WriteScopeInformation(textWriter, scopeProvider, sanitizeControlCharacters); // message if (!string.IsNullOrEmpty(message)) @@ -132,14 +137,15 @@ private static string GetSyslogSeverityString(LogLevel logLevel) }; } - private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider) + private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool sanitizeControlCharacters) { if (FormatterOptions.IncludeScopes && scopeProvider != null) { scopeProvider.ForEachScope((scope, state) => { state.Write(" => "); - state.Write(scope); + string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString(), sanitizeControlCharacters); + state.Write(scopeMessage); }, textWriter); } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs index 40b645b43f0f44..ef5cb7c26a9f3b 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs @@ -189,6 +189,56 @@ public void NullFormatterName_Throws() Assert.Throws(() => new NullNameConsoleFormatter()); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [MemberData(nameof(FormatterNames))] + public void Log_ControlCharacters_AreSanitized(string formatterName) + { + // Arrange + using var t = SetUp( + new ConsoleLoggerOptions { FormatterName = formatterName }, + new SimpleConsoleFormatterOptions { SanitizeControlCharacters = true, ColorBehavior = LoggerColorBehavior.Enabled }, + new ConsoleFormatterOptions { SanitizeControlCharacters = true }, + new JsonConsoleFormatterOptions { SanitizeControlCharacters = true }); + var logger = (ILogger)t.Logger; + var sink = t.Sink; + + // Act + logger.LogInformation("Payload: {Value}", "prefix\u001b[31mtext\u0008\u202E\r\n\tsuffix"); + + // Assert + string output = GetMessage(sink.Writes); + Assert.DoesNotContain('\u001b', output); + Assert.DoesNotContain('\u0008', output); + Assert.DoesNotContain('\u202E', output); + Assert.Contains("\\u001B", output); + Assert.Contains("\\u0008", output); + Assert.Contains("\\u202E", output); + Assert.Contains("\\u000D", output); + Assert.Contains("\\u000A", output); + Assert.Contains("\\u0009", output); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [MemberData(nameof(NonJsonFormatterNames))] + public void Log_ControlCharacters_SanitizationCanBeDisabled(string formatterName) + { + // Arrange + using var t = SetUp( + new ConsoleLoggerOptions { FormatterName = formatterName }, + new SimpleConsoleFormatterOptions { SanitizeControlCharacters = false, ColorBehavior = LoggerColorBehavior.Enabled }, + new ConsoleFormatterOptions { SanitizeControlCharacters = false }, + new JsonConsoleFormatterOptions { SanitizeControlCharacters = false }); + var logger = (ILogger)t.Logger; + var sink = t.Sink; + + // Act + logger.LogInformation("Payload: {Value}", "prefix\u202Esuffix"); + + // Assert + string output = GetMessage(sink.Writes); + Assert.Contains('\u202E', output); + } + private class NullNameConsoleFormatter : ConsoleFormatter { public NullNameConsoleFormatter() : base(null) { } @@ -226,6 +276,17 @@ public static TheoryData FormatterNames } } + public static TheoryData NonJsonFormatterNames + { + get + { + var data = new TheoryData(); + data.Add(ConsoleFormatterNames.Simple); + data.Add(ConsoleFormatterNames.Systemd); + return data; + } + } + public static TheoryData Levels { get diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs index 663b766ba4c92a..78919dfd19eb67 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs @@ -22,9 +22,9 @@ public void EnsureConsoleLoggerOptions_ConfigureOptions_SupportsAllProperties() BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; Assert.Equal(9, typeof(ConsoleLoggerOptions).GetProperties(flags).Length); - Assert.Equal(3, typeof(ConsoleFormatterOptions).GetProperties(flags).Length); - Assert.Equal(5, typeof(SimpleConsoleFormatterOptions).GetProperties(flags).Length); - Assert.Equal(4, typeof(JsonConsoleFormatterOptions).GetProperties(flags).Length); + Assert.Equal(4, typeof(ConsoleFormatterOptions).GetProperties(flags).Length); + Assert.Equal(6, typeof(SimpleConsoleFormatterOptions).GetProperties(flags).Length); + Assert.Equal(5, typeof(JsonConsoleFormatterOptions).GetProperties(flags).Length); Assert.Equal(7, typeof(JsonWriterOptions).GetProperties(flags).Length); } @@ -33,6 +33,7 @@ public void EnsureConsoleLoggerOptions_ConfigureOptions_SupportsAllProperties() [InlineData("Console:MaxQueueLength", "notANumber")] [InlineData("Console:QueueFullMode", "invalid")] [InlineData("Console:FormatterOptions:IncludeScopes", "not a bool")] + [InlineData("Console:FormatterOptions:SanitizeControlCharacters", "not a bool")] [InlineData("Console:FormatterOptions:UseUtcTimestamp", "not a bool")] [InlineData("Console:FormatterOptions:ColorBehavior", "not a behavior")] [InlineData("Console:FormatterOptions:SingleLine", "not a bool")] diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs index 48ec012b9490a9..dba97a977db1fe 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs @@ -715,8 +715,7 @@ public void WriteCore_LogsCorrectMessages(ConsoleLoggerFormat format, LogLevel l Assert.Equal( levelPrefix + ": test[0]" + Environment.NewLine + _paddingString + "This is a test, and {curly braces} are just fine!" + Environment.NewLine + - _paddingString + "System.Exception: Exception message" + Environment.NewLine + - _paddingString + "with a second line" + Environment.NewLine, + _paddingString + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -726,8 +725,7 @@ public void WriteCore_LogsCorrectMessages(ConsoleLoggerFormat format, LogLevel l Assert.Equal( levelPrefix + "test[0]" + " " + "This is a test, and {curly braces} are just fine!" + " " + - "System.Exception: Exception message" + " " + - "with a second line" + Environment.NewLine, + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -1117,8 +1115,7 @@ public void WriteCore_NullMessageWithException(ConsoleLoggerFormat format, LogLe Assert.Equal(2, sink.Writes.Count); Assert.Equal( levelPrefix + ": test[0]" + Environment.NewLine + - _paddingString + "System.Exception: Exception message" + Environment.NewLine + - _paddingString + "with a second line" + Environment.NewLine, + _paddingString + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -1127,8 +1124,7 @@ public void WriteCore_NullMessageWithException(ConsoleLoggerFormat format, LogLe Assert.Single(sink.Writes); Assert.Equal( levelPrefix + "test[0]" + " " + - "System.Exception: Exception message" + " " + - "with a second line" + Environment.NewLine, + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -1160,8 +1156,7 @@ public void WriteCore_EmptyMessageWithException(ConsoleLoggerFormat format, LogL Assert.Equal(2, sink.Writes.Count); Assert.Equal( levelPrefix + ": test[0]" + Environment.NewLine + - _paddingString + "System.Exception: Exception message" + Environment.NewLine + - _paddingString + "with a second line" + Environment.NewLine, + _paddingString + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -1170,8 +1165,7 @@ public void WriteCore_EmptyMessageWithException(ConsoleLoggerFormat format, LogL Assert.Single(sink.Writes); Assert.Equal( levelPrefix + "test[0]" + " " + - "System.Exception: Exception message" + " " + - "with a second line" + Environment.NewLine, + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs index 7f96c5d6d31d03..8d6a6124c2d0f8 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs @@ -526,12 +526,10 @@ public void ShouldContainInnerException(bool indented) static string GetContent(Exception exception) { - // Depending on OS, Environment.NewLine is either '\r\n' OR '\n' - string newLineReplacement = Environment.NewLine.Length == 2 ? "\\r\\n" : "\\n"; - return exception.ToString() .Replace(@"\", @"\\") // for paths in json content - .Replace(Environment.NewLine, newLineReplacement); + .Replace("\r", "\\\\u000D") + .Replace("\n", "\\\\u000A"); } [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] From 8e2eaec2e9cef1ba1f74937fc888fb581d48822c Mon Sep 17 00:00:00 2001 From: rosebyte Date: Wed, 17 Jun 2026 08:15:53 +0200 Subject: [PATCH 3/9] fix the pr --- .../Microsoft.Extensions.Logging.Console.cs | 1 - .../src/ConsoleControlCharacterSanitizer.cs | 31 +++++++++-- .../src/ConsoleFormatterOptions.cs | 8 --- .../src/JsonConsoleFormatter.cs | 28 ++++------ .../src/SimpleConsoleFormatter.cs | 13 +++-- .../src/SystemdConsoleFormatter.cs | 13 +++-- .../ConsoleFormatterTests.cs | 32 +++++------ .../ConsoleLoggerConfigureOptions.cs | 7 ++- .../ConsoleLoggerTest.cs | 53 +++---------------- .../JsonConsoleFormatterTests.cs | 6 ++- 10 files changed, 77 insertions(+), 115 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs b/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs index a26ba823eae0ed..76c9169cf61fa6 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/ref/Microsoft.Extensions.Logging.Console.cs @@ -77,7 +77,6 @@ public partial class ConsoleFormatterOptions { public ConsoleFormatterOptions() { } public bool IncludeScopes { get { throw null; } set { } } - public bool SanitizeControlCharacters { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.StringSyntaxAttribute("DateTimeFormat")] public string? TimestampFormat { get { throw null; } set { } } public bool UseUtcTimestamp { get { throw null; } set { } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs index 2d9e84b2ff2641..34557f3c85ddf3 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -8,9 +8,9 @@ namespace Microsoft.Extensions.Logging.Console { internal static class ConsoleControlCharacterSanitizer { - public static string? Sanitize(string? value, bool sanitizeControlCharacters) + public static string? Sanitize(string? value) { - if (!sanitizeControlCharacters || string.IsNullOrEmpty(value)) + if (string.IsNullOrEmpty(value)) { return value; } @@ -56,8 +56,31 @@ private static int GetFirstEscapedCharacterIndex(string value) private static bool ShouldEscape(char c) { - UnicodeCategory category = char.GetUnicodeCategory(c); - return category == UnicodeCategory.Control || category == UnicodeCategory.Format; + return c switch + { + '\u001B' => true, // ESC - ANSI escape sequences + '\u0007' => true, // BEL - terminal bell + '\u0008' => true, // BS - backspace + '\u007F' => true, // DEL - delete + '\u009B' => true, // CSI - control sequence introducer (8-bit) + '\u009C' => true, // ST - string terminator (8-bit) + '\u009D' => true, // OSC - operating system command (8-bit) + '\u200B' => true, // zero-width space + '\u200C' => true, // zero-width non-joiner + '\u200D' => true, // zero-width joiner + '\u200E' => true, // left-to-right mark + '\u200F' => true, // right-to-left mark + '\u202A' => true, // left-to-right embedding + '\u202B' => true, // right-to-left embedding + '\u202C' => true, // pop directional formatting + '\u202D' => true, // left-to-right override + '\u202E' => true, // right-to-left override + '\u2066' => true, // left-to-right isolate + '\u2067' => true, // right-to-left isolate + '\u2068' => true, // first strong isolate + '\u2069' => true, // pop directional isolate + _ => false, + }; } } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs index 671968bd74e34a..e97c3cbe0c88f5 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleFormatterOptions.cs @@ -41,14 +41,6 @@ public ConsoleFormatterOptions() { } /// public bool UseUtcTimestamp { get; set; } - /// - /// Gets or sets a value that indicates whether control and formatting characters are escaped in log output. - /// - /// - /// The default is . - /// - public bool SanitizeControlCharacters { get; set; } = true; - internal virtual void Configure(IConfiguration configuration) => configuration.Bind(this); } } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs index f19f49026455ba..14a30b9304f9fb 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs @@ -56,12 +56,6 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex string category, int eventId, string? exception, bool hasState, string? stateMessage, IReadOnlyList>? stateProperties, DateTimeOffset stamp) { - bool sanitizeControlCharacters = FormatterOptions.SanitizeControlCharacters; - message = ConsoleControlCharacterSanitizer.Sanitize(message, sanitizeControlCharacters); - category = ConsoleControlCharacterSanitizer.Sanitize(category, sanitizeControlCharacters)!; - exception = ConsoleControlCharacterSanitizer.Sanitize(exception, sanitizeControlCharacters); - stateMessage = ConsoleControlCharacterSanitizer.Sanitize(stateMessage, sanitizeControlCharacters); - const int DefaultBufferSize = 1024; using (var output = new PooledByteBufferWriter(DefaultBufferSize)) { @@ -71,7 +65,7 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex var timestampFormat = FormatterOptions.TimestampFormat; if (timestampFormat != null) { - writer.WriteString("Timestamp", ConsoleControlCharacterSanitizer.Sanitize(stamp.ToString(timestampFormat), sanitizeControlCharacters)); + writer.WriteString("Timestamp", stamp.ToString(timestampFormat)); } writer.WriteNumber(nameof(LogEntry.EventId), eventId); writer.WriteString(nameof(LogEntry.LogLevel), GetLogLevelString(logLevel)); @@ -97,12 +91,12 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex { foreach (KeyValuePair item in stateProperties) { - WriteItem(writer, item, sanitizeControlCharacters); + WriteItem(writer, item); } } writer.WriteEndObject(); } - WriteScopeInformation(writer, scopeProvider, sanitizeControlCharacters); + WriteScopeInformation(writer, scopeProvider); writer.WriteEndObject(); writer.Flush(); } @@ -136,7 +130,7 @@ private static string GetLogLevelString(LogLevel logLevel) }; } - private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider? scopeProvider, bool sanitizeControlCharacters) + private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider? scopeProvider) { if (FormatterOptions.IncludeScopes && scopeProvider != null) { @@ -146,25 +140,25 @@ private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider if (scope is IEnumerable> scopeItems) { state.WriteStartObject(); - state.WriteString("Message", ConsoleControlCharacterSanitizer.Sanitize(scope.ToString(), sanitizeControlCharacters)); + state.WriteString("Message", scope.ToString()); foreach (KeyValuePair item in scopeItems) { - WriteItem(state, item, sanitizeControlCharacters); + WriteItem(state, item); } state.WriteEndObject(); } else { - state.WriteStringValue(ConsoleControlCharacterSanitizer.Sanitize(ToInvariantString(scope), sanitizeControlCharacters)); + state.WriteStringValue(ToInvariantString(scope)); } }, writer); writer.WriteEndArray(); } } - private static void WriteItem(Utf8JsonWriter writer, KeyValuePair item, bool sanitizeControlCharacters) + private static void WriteItem(Utf8JsonWriter writer, KeyValuePair item) { - string key = ConsoleControlCharacterSanitizer.Sanitize(item.Key, sanitizeControlCharacters)!; + string key = item.Key; switch (item.Value) { case bool boolValue: @@ -177,7 +171,7 @@ private static void WriteItem(Utf8JsonWriter writer, KeyValuePair(in LogEntry logEntry, IExternalScopeP private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter textWriter, string message, LogLevel logLevel, int eventId, string? exception, string category, DateTimeOffset stamp) { - bool sanitizeControlCharacters = FormatterOptions.SanitizeControlCharacters; - message = ConsoleControlCharacterSanitizer.Sanitize(message, sanitizeControlCharacters)!; - exception = ConsoleControlCharacterSanitizer.Sanitize(exception, sanitizeControlCharacters); - category = ConsoleControlCharacterSanitizer.Sanitize(category, sanitizeControlCharacters)!; + message = ConsoleControlCharacterSanitizer.Sanitize(message)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception); + category = ConsoleControlCharacterSanitizer.Sanitize(category)!; ConsoleColors logLevelColors = GetLogLevelConsoleColors(logLevel); string logLevelString = GetLogLevelString(logLevel); @@ -117,7 +116,7 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex } // scope information - WriteScopeInformation(textWriter, scopeProvider, singleLine, sanitizeControlCharacters); + WriteScopeInformation(textWriter, scopeProvider, singleLine); WriteMessage(textWriter, message, singleLine); // Example: @@ -203,7 +202,7 @@ private ConsoleColors GetLogLevelConsoleColors(LogLevel logLevel) }; } - private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool singleLine, bool sanitizeControlCharacters) + private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool singleLine) { if (FormatterOptions.IncludeScopes && scopeProvider != null) { @@ -220,7 +219,7 @@ private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider { state.Write(" => "); } - string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString(), sanitizeControlCharacters); + string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString()); state.Write(scopeMessage); }, textWriter); diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs index 6536b99c3f2865..ded6068d778f77 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/SystemdConsoleFormatter.cs @@ -58,10 +58,9 @@ public override void Write(in LogEntry logEntry, IExternalScopeP private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter textWriter, string message, LogLevel logLevel, string category, int eventId, string? exception, DateTimeOffset stamp) { - bool sanitizeControlCharacters = FormatterOptions.SanitizeControlCharacters; - message = ConsoleControlCharacterSanitizer.Sanitize(message, sanitizeControlCharacters)!; - exception = ConsoleControlCharacterSanitizer.Sanitize(exception, sanitizeControlCharacters); - category = ConsoleControlCharacterSanitizer.Sanitize(category, sanitizeControlCharacters)!; + message = ConsoleControlCharacterSanitizer.Sanitize(message)!; + exception = ConsoleControlCharacterSanitizer.Sanitize(exception); + category = ConsoleControlCharacterSanitizer.Sanitize(category)!; // systemd reads messages from standard out line-by-line in a 'message' format. // newline characters are treated as message delimiters, so we must replace them. @@ -87,7 +86,7 @@ private void WriteInternal(IExternalScopeProvider? scopeProvider, TextWriter tex textWriter.Write(']'); // scope information - WriteScopeInformation(textWriter, scopeProvider, sanitizeControlCharacters); + WriteScopeInformation(textWriter, scopeProvider); // message if (!string.IsNullOrEmpty(message)) @@ -137,14 +136,14 @@ private static string GetSyslogSeverityString(LogLevel logLevel) }; } - private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool sanitizeControlCharacters) + private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider) { if (FormatterOptions.IncludeScopes && scopeProvider != null) { scopeProvider.ForEachScope((scope, state) => { state.Write(" => "); - string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString(), sanitizeControlCharacters); + string? scopeMessage = ConsoleControlCharacterSanitizer.Sanitize(scope?.ToString()); state.Write(scopeMessage); }, textWriter); } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs index ef5cb7c26a9f3b..d364be838e6e04 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs @@ -190,22 +190,19 @@ public void NullFormatterName_Throws() } [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] - [MemberData(nameof(FormatterNames))] - public void Log_ControlCharacters_AreSanitized(string formatterName) + [MemberData(nameof(NonJsonFormatterNames))] + public void Log_DangerousControlCharacters_AreSanitized(string formatterName) { - // Arrange using var t = SetUp( new ConsoleLoggerOptions { FormatterName = formatterName }, - new SimpleConsoleFormatterOptions { SanitizeControlCharacters = true, ColorBehavior = LoggerColorBehavior.Enabled }, - new ConsoleFormatterOptions { SanitizeControlCharacters = true }, - new JsonConsoleFormatterOptions { SanitizeControlCharacters = true }); + new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Enabled }, + new ConsoleFormatterOptions(), + new JsonConsoleFormatterOptions()); var logger = (ILogger)t.Logger; var sink = t.Sink; - // Act logger.LogInformation("Payload: {Value}", "prefix\u001b[31mtext\u0008\u202E\r\n\tsuffix"); - // Assert string output = GetMessage(sink.Writes); Assert.DoesNotContain('\u001b', output); Assert.DoesNotContain('\u0008', output); @@ -213,30 +210,25 @@ public void Log_ControlCharacters_AreSanitized(string formatterName) Assert.Contains("\\u001B", output); Assert.Contains("\\u0008", output); Assert.Contains("\\u202E", output); - Assert.Contains("\\u000D", output); - Assert.Contains("\\u000A", output); - Assert.Contains("\\u0009", output); } [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] [MemberData(nameof(NonJsonFormatterNames))] - public void Log_ControlCharacters_SanitizationCanBeDisabled(string formatterName) + public void Log_SafeWhitespace_IsPreserved(string formatterName) { - // Arrange using var t = SetUp( new ConsoleLoggerOptions { FormatterName = formatterName }, - new SimpleConsoleFormatterOptions { SanitizeControlCharacters = false, ColorBehavior = LoggerColorBehavior.Enabled }, - new ConsoleFormatterOptions { SanitizeControlCharacters = false }, - new JsonConsoleFormatterOptions { SanitizeControlCharacters = false }); + new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Enabled }, + new ConsoleFormatterOptions(), + new JsonConsoleFormatterOptions()); var logger = (ILogger)t.Logger; var sink = t.Sink; - // Act - logger.LogInformation("Payload: {Value}", "prefix\u202Esuffix"); + logger.LogInformation("Line1\nLine2\tIndented"); - // Assert string output = GetMessage(sink.Writes); - Assert.Contains('\u202E', output); + Assert.DoesNotContain("\\u000A", output); + Assert.DoesNotContain("\\u0009", output); } private class NullNameConsoleFormatter : ConsoleFormatter diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs index 78919dfd19eb67..663b766ba4c92a 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerConfigureOptions.cs @@ -22,9 +22,9 @@ public void EnsureConsoleLoggerOptions_ConfigureOptions_SupportsAllProperties() BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; Assert.Equal(9, typeof(ConsoleLoggerOptions).GetProperties(flags).Length); - Assert.Equal(4, typeof(ConsoleFormatterOptions).GetProperties(flags).Length); - Assert.Equal(6, typeof(SimpleConsoleFormatterOptions).GetProperties(flags).Length); - Assert.Equal(5, typeof(JsonConsoleFormatterOptions).GetProperties(flags).Length); + Assert.Equal(3, typeof(ConsoleFormatterOptions).GetProperties(flags).Length); + Assert.Equal(5, typeof(SimpleConsoleFormatterOptions).GetProperties(flags).Length); + Assert.Equal(4, typeof(JsonConsoleFormatterOptions).GetProperties(flags).Length); Assert.Equal(7, typeof(JsonWriterOptions).GetProperties(flags).Length); } @@ -33,7 +33,6 @@ public void EnsureConsoleLoggerOptions_ConfigureOptions_SupportsAllProperties() [InlineData("Console:MaxQueueLength", "notANumber")] [InlineData("Console:QueueFullMode", "invalid")] [InlineData("Console:FormatterOptions:IncludeScopes", "not a bool")] - [InlineData("Console:FormatterOptions:SanitizeControlCharacters", "not a bool")] [InlineData("Console:FormatterOptions:UseUtcTimestamp", "not a bool")] [InlineData("Console:FormatterOptions:ColorBehavior", "not a behavior")] [InlineData("Console:FormatterOptions:SingleLine", "not a bool")] diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs index dba97a977db1fe..9b621197f45915 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs @@ -715,7 +715,8 @@ public void WriteCore_LogsCorrectMessages(ConsoleLoggerFormat format, LogLevel l Assert.Equal( levelPrefix + ": test[0]" + Environment.NewLine + _paddingString + "This is a test, and {curly braces} are just fine!" + Environment.NewLine + - _paddingString + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, + _paddingString + "System.Exception: Exception message" + Environment.NewLine + + _paddingString + "with a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -725,7 +726,8 @@ public void WriteCore_LogsCorrectMessages(ConsoleLoggerFormat format, LogLevel l Assert.Equal( levelPrefix + "test[0]" + " " + "This is a test, and {curly braces} are just fine!" + " " + - "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, + "System.Exception: Exception message" + " " + + "with a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -1115,7 +1117,8 @@ public void WriteCore_NullMessageWithException(ConsoleLoggerFormat format, LogLe Assert.Equal(2, sink.Writes.Count); Assert.Equal( levelPrefix + ": test[0]" + Environment.NewLine + - _paddingString + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, + _paddingString + "System.Exception: Exception message" + Environment.NewLine + + _paddingString + "with a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; @@ -1124,48 +1127,8 @@ public void WriteCore_NullMessageWithException(ConsoleLoggerFormat format, LogLe Assert.Single(sink.Writes); Assert.Equal( levelPrefix + "test[0]" + " " + - "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, - GetMessage(sink.Writes)); - } - break; - default: - throw new ArgumentOutOfRangeException(nameof(format)); - } - } - - [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] - [MemberData(nameof(FormatsAndLevels))] - public void WriteCore_EmptyMessageWithException(ConsoleLoggerFormat format, LogLevel level) - { - // Arrange - using var t = SetUp(new ConsoleLoggerOptions { Format = format }); - var levelPrefix = t.GetLevelPrefix(level); - var logger = t.Logger; - var sink = t.Sink; - var ex = new Exception("Exception message" + Environment.NewLine + "with a second line"); - string message = string.Empty; - - // Act - logger.Log(level, 0, message, ex, (s, e) => s); - - // Assert - switch (format) - { - case ConsoleLoggerFormat.Default: - { - Assert.Equal(2, sink.Writes.Count); - Assert.Equal( - levelPrefix + ": test[0]" + Environment.NewLine + - _paddingString + "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, - GetMessage(sink.Writes)); - } - break; - case ConsoleLoggerFormat.Systemd: - { - Assert.Single(sink.Writes); - Assert.Equal( - levelPrefix + "test[0]" + " " + - "System.Exception: Exception message\\u000Awith a second line" + Environment.NewLine, + "System.Exception: Exception message" + " " + + "with a second line" + Environment.NewLine, GetMessage(sink.Writes)); } break; diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs index 8d6a6124c2d0f8..7f96c5d6d31d03 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/JsonConsoleFormatterTests.cs @@ -526,10 +526,12 @@ public void ShouldContainInnerException(bool indented) static string GetContent(Exception exception) { + // Depending on OS, Environment.NewLine is either '\r\n' OR '\n' + string newLineReplacement = Environment.NewLine.Length == 2 ? "\\r\\n" : "\\n"; + return exception.ToString() .Replace(@"\", @"\\") // for paths in json content - .Replace("\r", "\\\\u000D") - .Replace("\n", "\\\\u000A"); + .Replace(Environment.NewLine, newLineReplacement); } [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] From 0268f1dd26fde501ac5b68d99f4275667b48360c Mon Sep 17 00:00:00 2001 From: rosebyte Date: Wed, 17 Jun 2026 08:20:32 +0200 Subject: [PATCH 4/9] use ValueStringBuilder --- .../src/ConsoleControlCharacterSanitizer.cs | 5 +++-- .../src/Microsoft.Extensions.Logging.Console.csproj | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs index 34557f3c85ddf3..53c5df911e7d84 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Globalization; using System.Text; @@ -21,8 +22,8 @@ internal static class ConsoleControlCharacterSanitizer return value; } - var sanitized = new StringBuilder(value.Length + 8); - sanitized.Append(value, 0, firstEscapedCharacterIndex); + var sanitized = new ValueStringBuilder(stackalloc char[256]); + sanitized.Append(value.AsSpan(0, firstEscapedCharacterIndex)); for (int i = firstEscapedCharacterIndex; i < value.Length; i++) { diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj b/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj index 5999c85b907c42..c61223da404128 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/Microsoft.Extensions.Logging.Console.csproj @@ -19,6 +19,7 @@ + From f112ef0442a397c2367f25f549fdeaa624ac9ca7 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Wed, 17 Jun 2026 08:41:26 +0200 Subject: [PATCH 5/9] implement PR feedback --- .../src/ConsoleControlCharacterSanitizer.cs | 23 +++++++++-- .../ConsoleLoggerTest.cs | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs index 53c5df911e7d84..651d2a3d7f36dc 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Globalization; using System.Text; namespace Microsoft.Extensions.Logging.Console @@ -30,8 +29,14 @@ internal static class ConsoleControlCharacterSanitizer char current = value[i]; if (ShouldEscape(current)) { - sanitized.Append(@"\u"); - sanitized.Append(((int)current).ToString("X4", CultureInfo.InvariantCulture)); + sanitized.Append('\\'); + sanitized.Append('u'); + int codePoint = current; + Span hex = sanitized.AppendSpan(4); + hex[0] = ToHexChar(codePoint >> 12); + hex[1] = ToHexChar((codePoint >> 8) & 0xF); + hex[2] = ToHexChar((codePoint >> 4) & 0xF); + hex[3] = ToHexChar(codePoint & 0xF); } else { @@ -42,6 +47,9 @@ internal static class ConsoleControlCharacterSanitizer return sanitized.ToString(); } + private static char ToHexChar(int value) => + (char)(value < 10 ? '0' + value : 'A' + value - 10); + private static int GetFirstEscapedCharacterIndex(string value) { for (int i = 0; i < value.Length; i++) @@ -59,13 +67,20 @@ private static bool ShouldEscape(char c) { return c switch { - '\u001B' => true, // ESC - ANSI escape sequences + '\u0000' => true, // NUL - can truncate log lines in syslog/journald pipelines '\u0007' => true, // BEL - terminal bell '\u0008' => true, // BS - backspace + '\u000E' => true, // SO - shift out (invokes alternate character set) + '\u000F' => true, // SI - shift in + '\u001B' => true, // ESC - ANSI escape sequences '\u007F' => true, // DEL - delete + '\u0090' => true, // DCS - device control string (8-bit) '\u009B' => true, // CSI - control sequence introducer (8-bit) '\u009C' => true, // ST - string terminator (8-bit) '\u009D' => true, // OSC - operating system command (8-bit) + '\u0098' => true, // SOS - start of string (8-bit) + '\u009E' => true, // PM - privacy message (8-bit) + '\u009F' => true, // APC - application program command (8-bit) '\u200B' => true, // zero-width space '\u200C' => true, // zero-width non-joiner '\u200D' => true, // zero-width joiner diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs index 9b621197f45915..d00b834663afe3 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs @@ -1137,6 +1137,46 @@ public void WriteCore_NullMessageWithException(ConsoleLoggerFormat format, LogLe } } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [MemberData(nameof(FormatsAndLevels))] + public void WriteCore_EmptyMessageWithException(ConsoleLoggerFormat format, LogLevel level) + { + using var t = SetUp(new ConsoleLoggerOptions { Format = format }); + var levelPrefix = t.GetLevelPrefix(level); + var logger = t.Logger; + var sink = t.Sink; + var ex = new Exception("Exception message" + Environment.NewLine + "with a second line"); + string message = string.Empty; + + logger.Log(level, 0, message, ex, (s, e) => s); + + switch (format) + { + case ConsoleLoggerFormat.Default: + { + Assert.Equal(2, sink.Writes.Count); + Assert.Equal( + levelPrefix + ": test[0]" + Environment.NewLine + + _paddingString + "System.Exception: Exception message" + Environment.NewLine + + _paddingString + "with a second line" + Environment.NewLine, + GetMessage(sink.Writes)); + } + break; + case ConsoleLoggerFormat.Systemd: + { + Assert.Single(sink.Writes); + Assert.Equal( + levelPrefix + "test[0]" + " " + + "System.Exception: Exception message" + " " + + "with a second line" + Environment.NewLine, + GetMessage(sink.Writes)); + } + break; + default: + throw new ArgumentOutOfRangeException(nameof(format)); + } + } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] [MemberData(nameof(FormatsAndLevels))] public void WriteCore_MessageWithNullException(ConsoleLoggerFormat format, LogLevel level) From 8b81df2aed83b4df39bd4200147c1c4b226a1434 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Wed, 17 Jun 2026 08:58:42 +0200 Subject: [PATCH 6/9] clean up --- .../src/JsonConsoleFormatter.cs | 4 ++-- .../ConsoleLoggerTest.cs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs index 14a30b9304f9fb..040a4d09ae7fb7 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/JsonConsoleFormatter.cs @@ -158,7 +158,7 @@ private void WriteScopeInformation(Utf8JsonWriter writer, IExternalScopeProvider private static void WriteItem(Utf8JsonWriter writer, KeyValuePair item) { - string key = item.Key; + var key = item.Key; switch (item.Value) { case bool boolValue: @@ -171,7 +171,7 @@ private static void WriteItem(Utf8JsonWriter writer, KeyValuePair s); + // Assert switch (format) { case ConsoleLoggerFormat.Default: From 4c8e0fa40474d27f3b1af94521b6988befb3e197 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Wed, 17 Jun 2026 09:44:55 +0200 Subject: [PATCH 7/9] implement PR comments --- .../src/ConsoleControlCharacterSanitizer.cs | 65 +++++++++++-------- .../ConsoleFormatterTests.cs | 24 ++++++- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs index 651d2a3d7f36dc..0f661584d601bd 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -67,34 +67,43 @@ private static bool ShouldEscape(char c) { return c switch { - '\u0000' => true, // NUL - can truncate log lines in syslog/journald pipelines - '\u0007' => true, // BEL - terminal bell - '\u0008' => true, // BS - backspace - '\u000E' => true, // SO - shift out (invokes alternate character set) - '\u000F' => true, // SI - shift in - '\u001B' => true, // ESC - ANSI escape sequences - '\u007F' => true, // DEL - delete - '\u0090' => true, // DCS - device control string (8-bit) - '\u009B' => true, // CSI - control sequence introducer (8-bit) - '\u009C' => true, // ST - string terminator (8-bit) - '\u009D' => true, // OSC - operating system command (8-bit) - '\u0098' => true, // SOS - start of string (8-bit) - '\u009E' => true, // PM - privacy message (8-bit) - '\u009F' => true, // APC - application program command (8-bit) - '\u200B' => true, // zero-width space - '\u200C' => true, // zero-width non-joiner - '\u200D' => true, // zero-width joiner - '\u200E' => true, // left-to-right mark - '\u200F' => true, // right-to-left mark - '\u202A' => true, // left-to-right embedding - '\u202B' => true, // right-to-left embedding - '\u202C' => true, // pop directional formatting - '\u202D' => true, // left-to-right override - '\u202E' => true, // right-to-left override - '\u2066' => true, // left-to-right isolate - '\u2067' => true, // right-to-left isolate - '\u2068' => true, // first strong isolate - '\u2069' => true, // pop directional isolate + '\u0000' => true, // NUL + '\u0001' => true, // SOH + '\u0002' => true, // STX + '\u0003' => true, // ETX + '\u0004' => true, // EOT + '\u0005' => true, // ENQ + '\u0006' => true, // ACK + '\u0007' => true, // BEL + '\u0008' => true, // BS + // '\u0009' HT (tab) - preserved for log formatting + // '\u000A' LF (newline) - preserved for log formatting + '\u000B' => true, // VT + '\u000C' => true, // FF + // '\u000D' CR (carriage return) - preserved for log formatting + '\u000E' => true, // SO + '\u000F' => true, // SI + '\u0010' => true, // DLE + '\u0011' => true, // DC1 + '\u0012' => true, // DC2 + '\u0013' => true, // DC3 + '\u0014' => true, // DC4 + '\u0015' => true, // NAK + '\u0016' => true, // SYN + '\u0017' => true, // ETB + '\u0018' => true, // CAN + '\u0019' => true, // EM + '\u001A' => true, // SUB + '\u001B' => true, // ESC + '\u001C' => true, // FS + '\u001D' => true, // GS + '\u001E' => true, // RS + '\u001F' => true, // US + '\u007F' => true, // DEL + >= '\u0080' and <= '\u009F' => true, // C1 control range + >= '\u200B' and <= '\u200F' => true, // zero-width and directional marks + >= '\u202A' and <= '\u202E' => true, // bidi embedding/override + >= '\u2066' and <= '\u2069' => true, // bidi isolates _ => false, }; } diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs index d364be838e6e04..caaad5ffc88744 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs @@ -195,7 +195,7 @@ public void Log_DangerousControlCharacters_AreSanitized(string formatterName) { using var t = SetUp( new ConsoleLoggerOptions { FormatterName = formatterName }, - new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Enabled }, + new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Disabled }, new ConsoleFormatterOptions(), new JsonConsoleFormatterOptions()); var logger = (ILogger)t.Logger; @@ -218,7 +218,7 @@ public void Log_SafeWhitespace_IsPreserved(string formatterName) { using var t = SetUp( new ConsoleLoggerOptions { FormatterName = formatterName }, - new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Enabled }, + new SimpleConsoleFormatterOptions { ColorBehavior = LoggerColorBehavior.Disabled }, new ConsoleFormatterOptions(), new JsonConsoleFormatterOptions()); var logger = (ILogger)t.Logger; @@ -231,6 +231,26 @@ public void Log_SafeWhitespace_IsPreserved(string formatterName) Assert.DoesNotContain("\\u0009", output); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [InlineData(ConsoleFormatterNames.Json)] + public void Log_Json_ControlCharacters_EscapedByWriter(string formatterName) + { + using var t = SetUp( + new ConsoleLoggerOptions { FormatterName = formatterName }, + new SimpleConsoleFormatterOptions(), + new ConsoleFormatterOptions(), + new JsonConsoleFormatterOptions()); + var logger = (ILogger)t.Logger; + var sink = t.Sink; + + logger.LogInformation("Payload: {Value}", "prefix\u001b[31mtext\u0008\u202Esuffix"); + + string output = GetMessage(sink.Writes); + Assert.DoesNotContain('\u001b', output); + Assert.DoesNotContain('\u0008', output); + Assert.DoesNotContain('\u202E', output); + } + private class NullNameConsoleFormatter : ConsoleFormatter { public NullNameConsoleFormatter() : base(null) { } From 750109ff1533f5e9347a1ba823bf6b6e1a4dd179 Mon Sep 17 00:00:00 2001 From: Jaroslav Ruzicka <14963300+rosebyte@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:08:29 +0200 Subject: [PATCH 8/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../ConsoleFormatterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs index caaad5ffc88744..213eb16479f39b 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleFormatterTests.cs @@ -214,7 +214,7 @@ public void Log_DangerousControlCharacters_AreSanitized(string formatterName) [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] [MemberData(nameof(NonJsonFormatterNames))] - public void Log_SafeWhitespace_IsPreserved(string formatterName) + public void Log_SafeWhitespace_IsNotEscaped(string formatterName) { using var t = SetUp( new ConsoleLoggerOptions { FormatterName = formatterName }, From 2381a01fefca0f651785a680cfda8b7e23b03099 Mon Sep 17 00:00:00 2001 From: Jaroslav Ruzicka <14963300+rosebyte@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:47:50 +0200 Subject: [PATCH 9/9] Update src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jiri Cincura ↹ --- .../src/ConsoleControlCharacterSanitizer.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs index 0f661584d601bd..2c46f8f2ea1ed4 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleControlCharacterSanitizer.cs @@ -33,10 +33,10 @@ internal static class ConsoleControlCharacterSanitizer sanitized.Append('u'); int codePoint = current; Span hex = sanitized.AppendSpan(4); - hex[0] = ToHexChar(codePoint >> 12); - hex[1] = ToHexChar((codePoint >> 8) & 0xF); - hex[2] = ToHexChar((codePoint >> 4) & 0xF); - hex[3] = ToHexChar(codePoint & 0xF); + hex[0] = HexConverter.ToCharUpper(current >> 12); + hex[1] = HexConverter.ToCharUpper(current >> 8); + hex[2] = HexConverter.ToCharUpper(current >> 4); + hex[3] = HexConverter.ToCharUpper(current); } else {