diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index d01321c1d9..efe5a8d00e 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -238,7 +238,7 @@ public void FillRect (Rectangle rect, Rune rune) // So we inline the logic instead. SetAttributeAndDirty (c, r); InvalidateOverlappedWideGlyph (c, r); - string grapheme = rune != default (Rune) ? rune.ToString () : " "; + string grapheme = rune != default (Rune) ? rune.ToString ().MakePrintable () : " "; WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); } } diff --git a/Terminal.Gui/Text/RuneExtensions.cs b/Terminal.Gui/Text/RuneExtensions.cs index ffa20ddb62..075a525248 100644 --- a/Terminal.Gui/Text/RuneExtensions.cs +++ b/Terminal.Gui/Text/RuneExtensions.cs @@ -198,11 +198,11 @@ public static bool IsSurrogatePair (this Rune rune) } /// - /// Ensures the rune is not a control character and can be displayed by translating characters below 0x20 to - /// equivalent, printable, Unicode chars. + /// Ensures the rune is not a control character and can be displayed by translating C0 controls (U+0000–U+001F), + /// DEL (U+007F), and C1 controls (U+0080–U+009F) to printable Unicode equivalents via the +U+2400 offset. /// /// This is a Terminal.Gui extension method to to support TUI text manipulation. - /// - /// + /// The rune to make printable. + /// A printable rune safe for terminal display. public static Rune MakePrintable (this Rune rune) { return Rune.IsControl (rune) ? new (rune.Value + 0x2400) : rune; } } diff --git a/Terminal.Gui/Text/StringExtensions.cs b/Terminal.Gui/Text/StringExtensions.cs index 4693a28d7d..ec923de921 100644 --- a/Terminal.Gui/Text/StringExtensions.cs +++ b/Terminal.Gui/Text/StringExtensions.cs @@ -132,22 +132,45 @@ public static bool IsSurrogatePair (this string str) } /// - /// Ensures the text is not a control character and can be displayed by translating characters below 0x20 to - /// equivalent, printable, Unicode chars. + /// Ensures the text does not contain control characters that could be emitted verbatim to the terminal, + /// by translating C0 controls (U+0000–U+001F), DEL (U+007F), and C1 controls (U+0080–U+009F) to + /// printable Unicode equivalents via the +U+2400 offset. Multi-character graphemes whose first + /// character is a control are replaced with a space. /// - /// This is a Terminal.Gui extension method to to support TUI text manipulation. + /// + /// This is a Terminal.Gui extension method to to support TUI text manipulation. + /// + /// Per UAX #29, control characters are always grapheme cluster boundaries. A well-formed grapheme + /// cluster produced by + /// cannot contain embedded controls, so only the first character needs to be checked. + /// + /// /// The text. - /// + /// A string safe for terminal display. public static string MakePrintable (this string str) { - if (str.Length > 1) + if (string.IsNullOrEmpty (str)) { return str; } - char ch = str [0]; + char first = str [0]; + + // Fast path: single-char grapheme (covers the vast majority of calls) + if (str.Length == 1) + { + return char.IsControl (first) ? new string ((char)(first + 0x2400), 1) : str; + } + + // Multi-char grapheme: per UAX #29, control characters are grapheme cluster boundaries, + // so a well-formed cluster from GetTextElementEnumerator cannot contain embedded controls. + // We only need to check the first character defensively for malformed input. + if (char.IsControl (first)) + { + return " "; + } - return char.IsControl (ch) ? new string ((char)(ch + 0x2400), 1) : str; + return str; } /// Repeats the string times. diff --git a/Tests/UnitTestsParallelizable/Text/RuneTests.cs b/Tests/UnitTestsParallelizable/Text/RuneTests.cs index a2ec053f92..70ec54fc54 100644 --- a/Tests/UnitTestsParallelizable/Text/RuneTests.cs +++ b/Tests/UnitTestsParallelizable/Text/RuneTests.cs @@ -313,7 +313,7 @@ public void MakePrintable_Combining_Character_Is_Not_Printable (int code) [Theory] [InlineData (0x0000001F, 0x241F)] [InlineData (0x0000007F, 0x247F)] - [InlineData (0x0000009F, 0x249F)] + [InlineData (0x0000009F, 0x249F)] // C1 control → +0x2400 offset for distinct visual [InlineData (0x0001001A, 0x1001A)] public void MakePrintable_Converts_Control_Chars_To_Proper_Unicode (int code, int expected) { @@ -366,11 +366,12 @@ public void Rune_ColumnWidth_Versus_String_ConsoleWidth (string text, int string public void Rune_Exceptions_Integers (int code) { Assert.Throws (() => new Rune (code)); } [Theory] - // Control characters (should be mapped to Control Pictures) + // Control characters (should be mapped to Control Pictures via +U+2400 offset) [InlineData ('\u0000', 0x2400)] // NULL → ␀ [InlineData ('\u0009', 0x2409)] // TAB → ␉ [InlineData ('\u000A', 0x240A)] // LF → ␊ [InlineData ('\u000D', 0x240D)] // CR → ␍ + [InlineData ('\u001B', 0x241B)] // ESC → ␛ // Printable characters (should remain unchanged) [InlineData ('A', 'A')] diff --git a/Tests/UnitTestsParallelizable/Text/StringTests.cs b/Tests/UnitTestsParallelizable/Text/StringTests.cs index 656fa7ae53..9b104f6d92 100644 --- a/Tests/UnitTestsParallelizable/Text/StringTests.cs +++ b/Tests/UnitTestsParallelizable/Text/StringTests.cs @@ -215,21 +215,30 @@ public void IsSurrogatePair_ReturnsExpected (string input, bool expected) } [Theory] - // Control characters (should be replaced with the "Control Pictures" block) + // Control characters (should be replaced with the "Control Pictures" block via +U+2400 offset) [InlineData ("\u0000", "\u2400")] // NULL → ␀ [InlineData ("\u0009", "\u2409")] // TAB → ␉ [InlineData ("\u000A", "\u240A")] // LF → ␊ [InlineData ("\u000D", "\u240D")] // CR → ␍ + [InlineData ("\u001B", "\u241B")] // ESC → ␛ + + // C1 controls (mapped via +U+2400 offset for distinct visuals) + [InlineData ("\u007F", "\u247F")] // DEL → Control Picture + [InlineData ("\u0080", "\u2480")] // C1 control → distinct visual + [InlineData ("\u009F", "\u249F")] // C1 control → distinct visual // Printable characters (should remain unchanged) [InlineData ("A", "A")] [InlineData (" ", " ")] [InlineData ("~", "~")] - // Multi-character string (should return unchanged) + // Multi-character strings (control at start → space, no controls → unchanged) [InlineData ("AB", "AB")] [InlineData ("Hello", "Hello")] - [InlineData ("\u0009A", "\u0009A")] // includes a control char, but length > 1 + + // Copilot - Security fix: multi-char graphemes starting with control chars are sanitized + [InlineData ("\u001BA", " ")] // ESC + A → space (unsafe control at start) + [InlineData ("\u0009A", " ")] // TAB + A → space (control at start of multi-char) public void MakePrintable_ReturnsExpected (string input, string expected) { // Act