From e0afdb6f0e6af8e7953186b6aee8a293510a0405 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:27:41 +0000 Subject: [PATCH] Escape a lone surrogate in a quoted string literal [patch] QuoteStringLiteral copied an unpaired surrogate into the literal raw. It has no UTF-8 encoding, so saving the generated file either threw or silently replaced the character with U+FFFD, changing the constant's value. A surrogate that is not half of a valid pair now takes a \uXXXX escape; valid pairs are still written as they are. Fixes #130 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014h6btvAuM1Ew7cCFSpuFWc --- CodeBlocker.Test/TemplateTests.cs | 26 +++++++++++++++++ CodeBlocker/Templates/TemplateRendering.cs | 34 +++++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index 5c40f52..cebf078 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -2,6 +2,7 @@ namespace CodeBlocker.Tests; +using System.Text; using ktsu.CodeBlocker; using ktsu.CodeBlocker.Templates; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -48,6 +49,31 @@ public void ParameterEscapesAQuotedDefaultValue(string value, string expectedLit $"string name = {expectedLiteral}", Render(new ParameterTemplate { Type = "string", Name = "name", DefaultValue = value, DefaultValueIsQuoted = true })); + [TestMethod] + public void ParameterEscapesALoneSurrogateInAQuotedDefaultValue() + { + // These are built in code rather than passed through DataRow: an attribute argument is + // stored as UTF-8, which cannot carry a lone surrogate either. + (string Value, string ExpectedLiteral)[] cases = + [ + ("a\uD800b", @"""a\ud800b"""), + ("a\uDC00b", @"""a\udc00b"""), + ("end\uD83D", @"""end\ud83d"""), + ("\uDE00start", @"""\ude00start"""), + ("\uDE00\uD83D", @"""\ude00\ud83d"""), + ("pair \uD83D\uDE00", "\"pair \uD83D\uDE00\""), + ]; + + UTF8Encoding strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + foreach ((string value, string expectedLiteral) in cases) + { + string rendered = Render(new ParameterTemplate { Type = "string", Name = "name", DefaultValue = value, DefaultValueIsQuoted = true }); + + Assert.AreEqual($"string name = {expectedLiteral}", rendered); + _ = strictUtf8.GetBytes(rendered); + } + } + [TestMethod] public void ParameterKeepsItsAttributesOnTheDeclarationLine() { diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs index 017955e..11bf57b 100644 --- a/CodeBlocker/Templates/TemplateRendering.cs +++ b/CodeBlocker/Templates/TemplateRendering.cs @@ -125,15 +125,18 @@ internal static void SpliceFragment(CodeBlocker parent, string fragment) /// The quoted literal. /// /// Backslashes, quotes and the common control characters take their short escapes; any other - /// control character, and the line and paragraph separators the compiler also treats as line - /// breaks, take a \uXXXX escape. + /// control character, the line and paragraph separators the compiler also treats as line + /// breaks, and any surrogate that is not half of a valid pair take a \uXXXX escape. A + /// lone surrogate has no UTF-8 encoding, so written raw it would make the generated file + /// impossible to save faithfully: a strict encoder throws and a replacing one changes the value. /// internal static string QuoteStringLiteral(string value) { StringBuilder literal = new(value.Length + 2); literal.Append('"'); - foreach (char c in value) + for (int i = 0; i < value.Length; i++) { + char c = value[i]; string? escape = c switch { '\\' => @"\\", @@ -146,7 +149,7 @@ internal static string QuoteStringLiteral(string value) '\r' => @"\r", '\t' => @"\t", '\v' => @"\v", - _ when char.IsControl(c) || c is '\u2028' or '\u2029' => + _ when char.IsControl(c) || c is '\u2028' or '\u2029' || IsLoneSurrogate(value, i) => @"\u" + ((int)c).ToString("x4", CultureInfo.InvariantCulture), _ => null, }; @@ -165,6 +168,29 @@ _ when char.IsControl(c) || c is '\u2028' or '\u2029' => return literal.ToString(); } + /// + /// Reports whether the character at is a surrogate that is not half of + /// a high-low pair. + /// + /// The text. + /// The position of the character to test. + /// for an unpaired surrogate; otherwise . + private static bool IsLoneSurrogate(string value, int index) + { + char c = value[index]; + if (char.IsHighSurrogate(c)) + { + return index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1]); + } + + if (char.IsLowSurrogate(c)) + { + return index == 0 || !char.IsHighSurrogate(value[index - 1]); + } + + return false; + } + /// /// Writes a parenthesised, comma-separated parameter list. ///