Compare commits

...

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0557b362e6 Add MarkdownTextHelper sanitizer to strip control chars that crash WinUI RichTextBlock
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/198cc9b8-bdc8-4189-ad27-5f61e56c7da0

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-04-29 10:35:18 +00:00
copilot-swe-agent[bot]
cbb711ab73 Initial plan 2026-04-29 08:51:01 +00:00
4 changed files with 138 additions and 3 deletions

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -25,7 +26,7 @@ public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, WeakRe
return;
}
Body = model.Body;
Body = MarkdownTextHelper.SanitizeMarkdown(model.Body);
UpdateProperty(nameof(Body));
model.PropChanged += Model_PropChanged;
@@ -55,7 +56,7 @@ public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, WeakRe
switch (propertyName)
{
case nameof(Body):
Body = model.Body;
Body = MarkdownTextHelper.SanitizeMarkdown(model.Body);
break;
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -34,7 +35,7 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
}
Title = model.Title ?? string.Empty;
Body = model.Body ?? string.Empty;
Body = MarkdownTextHelper.SanitizeMarkdown(model.Body);
HeroImage = new(model.HeroImage);
HeroImage.InitializeProperties();

View File

@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
namespace Microsoft.CmdPal.UI.ViewModels.Helpers;
/// <summary>
/// Provides helper methods for sanitizing text before passing it to the WinUI MarkdownTextBlock
/// control, which wraps RichTextBlock internally. Certain control characters can trigger a native
/// crash (access violation) in RichTextBlock's text-selection code path when the user double-clicks
/// to select a word.
/// </summary>
/// <remarks>
/// See <see href="https://github.com/microsoft/microsoft-ui-xaml/issues/7299"/> for the upstream
/// WinUI bug. This is a best-effort, client-side mitigation.
/// </remarks>
internal static partial class MarkdownTextHelper
{
// Matches characters that are known to destabilize WinUI's RichTextBlock:
// - C0 control codes (U+0000U+001F) except TAB (U+0009), LF (U+000A), CR (U+000D)
// - DEL (U+007F)
// - C1 control codes (U+0080U+009F)
[GeneratedRegex(@"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F]", RegexOptions.CultureInvariant)]
private static partial Regex ProblematicControlCharsRegex();
/// <summary>
/// Removes control characters from <paramref name="text"/> that are known to cause crashes in
/// the WinUI <c>RichTextBlock</c> control's double-tap word-selection code path.
/// Standard whitespace (TAB, LF, CR) is preserved because Markdown relies on it.
/// </summary>
/// <param name="text">The raw markdown string, possibly <see langword="null"/>.</param>
/// <returns>The sanitized string, or <see cref="string.Empty"/> if <paramref name="text"/> is null.</returns>
internal static string SanitizeMarkdown(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text ?? string.Empty;
}
return ProblematicControlCharsRegex().Replace(text, string.Empty);
}
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Helpers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class MarkdownTextHelperTests
{
[TestMethod]
public void SanitizeMarkdown_NullInput_ReturnsEmpty()
{
var result = MarkdownTextHelper.SanitizeMarkdown(null);
Assert.AreEqual(string.Empty, result);
}
[TestMethod]
public void SanitizeMarkdown_EmptyInput_ReturnsEmpty()
{
var result = MarkdownTextHelper.SanitizeMarkdown(string.Empty);
Assert.AreEqual(string.Empty, result);
}
[TestMethod]
public void SanitizeMarkdown_CleanMarkdown_ReturnsUnchanged()
{
const string input = "# Hello\n\nThis is **bold** and _italic_.\n\n- Item 1\n- Item 2\n";
var result = MarkdownTextHelper.SanitizeMarkdown(input);
Assert.AreEqual(input, result);
}
[DataTestMethod]
[DataRow("\x00")] // NUL
[DataRow("\x01")] // SOH
[DataRow("\x07")] // BEL
[DataRow("\x08")] // BS
[DataRow("\x0B")] // VT (Vertical Tab)
[DataRow("\x0C")] // FF (Form Feed)
[DataRow("\x0E")] // SO
[DataRow("\x1F")] // US
[DataRow("\x7F")] // DEL
[DataRow("\x80")] // C1 start
[DataRow("\x9F")] // C1 end
public void SanitizeMarkdown_SingleControlChar_IsRemoved(string controlChar)
{
var input = $"before{controlChar}after";
var result = MarkdownTextHelper.SanitizeMarkdown(input);
Assert.AreEqual("beforeafter", result);
}
[TestMethod]
public void SanitizeMarkdown_NulBytesWithinText_AreRemoved()
{
// NUL bytes in clipboard content (the main bug trigger from the issue)
var input = "Hello\x00World\x00";
var result = MarkdownTextHelper.SanitizeMarkdown(input);
Assert.AreEqual("HelloWorld", result);
}
[TestMethod]
public void SanitizeMarkdown_StandardWhitespacePreserved()
{
// TAB (0x09), LF (0x0A), CR (0x0D) must be kept — Markdown relies on them
const string input = "Column1\tColumn2\r\nLine2\nLine3";
var result = MarkdownTextHelper.SanitizeMarkdown(input);
Assert.AreEqual(input, result);
}
[TestMethod]
public void SanitizeMarkdown_MixedControlAndNormalChars_OnlyControlCharsRemoved()
{
// Simulates clipboard text with embedded NUL bytes interspersed with normal text
var input = "This is a short \x00demo paragraph, meant \x00to include literal N\x00UL bytes.";
var result = MarkdownTextHelper.SanitizeMarkdown(input);
Assert.AreEqual("This is a short demo paragraph, meant to include literal NUL bytes.", result);
}
[TestMethod]
public void SanitizeMarkdown_UnicodeAndEmoji_NotAffected()
{
// Non-ASCII and emoji must pass through unchanged
const string input = "Café ☕ \U0001F600 résumé";
var result = MarkdownTextHelper.SanitizeMarkdown(input);
Assert.AreEqual(input, result);
}
}