File: ConsoleControlCharacterSanitizer.cs
Web Access
Project: src\runtime\src\libraries\Microsoft.Extensions.Logging.Console\src\Microsoft.Extensions.Logging.Console.csproj (Microsoft.Extensions.Logging.Console)
// 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.Text;
#if NET
using System.Buffers;
using System.Collections.Generic;
#endif

namespace Microsoft.Extensions.Logging.Console
{
    internal static class ConsoleControlCharacterSanitizer
    {
#if NET
        // Built from ShouldEscape so the vectorized scan can never drift from the scalar predicate.
        private static readonly SearchValues<char> s_charsToEscape = CreateSearchValues();
#endif

        public static string? Sanitize(string? value)
        {
            if (string.IsNullOrEmpty(value))
            {
                return value;
            }

            ReadOnlySpan<char> remaining = value.AsSpan();
            int firstEscapedCharacterIndex = IndexOfFirstCharToEscape(remaining);
            if (firstEscapedCharacterIndex < 0)
            {
                return value;
            }

            var sanitized = new ValueStringBuilder(stackalloc char[256]);
            sanitized.Append(remaining.Slice(0, firstEscapedCharacterIndex));
            remaining = remaining.Slice(firstEscapedCharacterIndex);

            while (true)
            {
                // remaining[0] is always a character that must be escaped.
                AppendEscaped(ref sanitized, remaining[0]);
                remaining = remaining.Slice(1);

                int next = IndexOfFirstCharToEscape(remaining);
                if (next < 0)
                {
                    sanitized.Append(remaining);
                    break;
                }

                sanitized.Append(remaining.Slice(0, next));
                remaining = remaining.Slice(next);
            }

            return sanitized.ToString();
        }

        private static void AppendEscaped(ref ValueStringBuilder builder, char c)
        {
            Span<char> escaped = builder.AppendSpan(6);
            escaped[0] = '\\';
            escaped[1] = 'u';
            escaped[2] = ToHexChar(c >> 12);
            escaped[3] = ToHexChar(c >> 8);
            escaped[4] = ToHexChar(c >> 4);
            escaped[5] = ToHexChar(c);
        }

        private static char ToHexChar(int nibble)
        {
            nibble &= 0xF;
            return (char)(nibble < 10 ? '0' + nibble : 'A' + nibble - 10);
        }

        private static int IndexOfFirstCharToEscape(ReadOnlySpan<char> value)
        {
#if NET
            return value.IndexOfAny(s_charsToEscape);
#else
            for (int i = 0; i < value.Length; i++)
            {
                if (ShouldEscape(value[i]))
                {
                    return i;
                }
            }

            return -1;
#endif
        }

#if NET
        private static SearchValues<char> CreateSearchValues()
        {
            // ShouldEscape only returns true for characters in the C0/DEL/C1 range (<= U+009F),
            // so there is no need to scan the rest of the BMP when building the set.
            var chars = new List<char>();
            for (int c = 0; c <= 0x9F; c++)
            {
                if (ShouldEscape((char)c))
                {
                    chars.Add((char)c);
                }
            }

            return SearchValues.Create(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(chars));
        }
#endif

        private static bool ShouldEscape(char c)
        {
            // Escape the control characters that can drive terminal escape sequences when written to a
            // console: the C0 range (U+0000-U+001F), DEL (U+007F) and the C1 range (U+0080-U+009F). These
            // are the same ranges sanitized by systemd and OpenSSH for terminal output. \t, \n and \r are
            // control characters but are preserved for log formatting.
            if (c is '\t' or '\n' or '\r')
            {
                return false;
            }

            return c <= '\u001F' || (c >= '\u007F' && c <= '\u009F');
        }
    }
}