From 67ffb9fc0c905e8bd1debaa7a6bd8bba741b9c8a Mon Sep 17 00:00:00 2001 From: gaoflow Date: Fri, 31 Jul 2026 21:07:21 +0200 Subject: [PATCH 1/2] fix: decode RFC 5545 and RFC 6868 escapes in a single pass UnescapeText and ParameterExtensions.Unescape used sequential string.Replace calls whose tokens overlap the escaped-escape token, so an escaped literal decoded to the wrong character: - \\n (literal backslash + n) became a newline instead of \n - ^^n / ^^' (escaped caret + n / ') became ^ + newline / ^" instead of ^n / ^' Replace the sequential replaces with one forward scan that consumes each escape token once; undefined escapes keep both characters. --- .../EscapingExtensionsTest.cs | 171 ++++++++++++++++++ .../Properties/EscapingExtensions.cs | 36 +++- .../Properties/PropertyParameterExtensions.cs | 35 +++- 3 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 VSyntaxReader.Tests/PropertiesGeneric/EscapingExtensionsTest.cs diff --git a/VSyntaxReader.Tests/PropertiesGeneric/EscapingExtensionsTest.cs b/VSyntaxReader.Tests/PropertiesGeneric/EscapingExtensionsTest.cs new file mode 100644 index 0000000..523d402 --- /dev/null +++ b/VSyntaxReader.Tests/PropertiesGeneric/EscapingExtensionsTest.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Calendare.VSyntaxReader; +using Calendare.VSyntaxReader.Components; +using Calendare.VSyntaxReader.Properties; + +namespace VSyntaxReader.Tests.PropertiesGeneric; + +public class EscapingExtensionsTest +{ + private const char Bs = '\\'; + private const char Lf = '\n'; + private const char Caret = '^'; + private const char Quote = '"'; + private const char Apostrophe = '\''; + + private static string S(params char[] chars) => new string(chars); + + private static string CaretEscape(string value) + => value.Replace("^", "^^").Replace("\n", "^n").Replace("\"", "^'"); + + [Fact] + public void TextEscapingRoundTrips() + { + string[] inputs = + [ + S(Bs, 'n'), // literal backslash + n must not decode to a newline + S(Bs, 'N'), + S(Bs, Bs, 'n'), + S(Bs, 'n', 'x'), + S(Bs, Bs), + S(Bs, ';'), + S(Bs, ','), + S(Bs, Lf), + S(Bs, 'b'), + S(Lf), + S(Bs), + S(Bs, 'n', Bs, 'N', Lf), + "plain text, with; separators", + ]; + foreach (var input in inputs) + { + var escaped = EscapingExtensions.EscapeText(input); + Assert.Equal(input, EscapingExtensions.UnescapeText(escaped)); + } + } + + [Fact] + public void CaretEscapingRoundTrips() + { + string[] inputs = + [ + S(Caret, 'n'), // literal caret + n must not decode to caret + newline + S(Caret, Apostrophe), + S(Caret, Caret, 'n'), + S(Caret), + S(Quote), + S(Caret, Lf), + S(Caret, 'b'), + S(Lf), + S(Caret, 'n', Caret, Apostrophe, Quote, Lf), + "plain, value; text", + ]; + foreach (var input in inputs) + { + var escaped = CaretEscape(input); + Assert.Equal(input, ParameterExtensions.Unescape(escaped)); + } + } + + [Fact] + public void TextUnescapeDecodesEscapedTokens() + { + Assert.Equal(S(Lf), EscapingExtensions.UnescapeText(S(Bs, 'n'))); + Assert.Equal(S(Lf), EscapingExtensions.UnescapeText(S(Bs, 'N'))); + Assert.Equal(S(';'), EscapingExtensions.UnescapeText(S(Bs, ';'))); + Assert.Equal(S(','), EscapingExtensions.UnescapeText(S(Bs, ','))); + Assert.Equal(S(Bs), EscapingExtensions.UnescapeText(S(Bs, Bs))); + // RFC 5545: an undefined escape keeps the backslash. + Assert.Equal(S(Bs, 'b'), EscapingExtensions.UnescapeText(S(Bs, 'b'))); + Assert.Equal(S('a', Bs), EscapingExtensions.UnescapeText(S('a', Bs))); + Assert.Null(EscapingExtensions.UnescapeText("")); + } + + [Fact] + public void CaretUnescapeDecodesEscapedTokens() + { + Assert.Equal(S(Lf), ParameterExtensions.Unescape(S(Caret, 'n'))); + Assert.Equal(S(Quote), ParameterExtensions.Unescape(S(Caret, Apostrophe))); + Assert.Equal(S(Caret), ParameterExtensions.Unescape(S(Caret, Caret))); + // RFC 6868: an undefined caret escape keeps the caret. + Assert.Equal(S(Caret, 'b'), ParameterExtensions.Unescape(S(Caret, 'b'))); + Assert.Equal(S('a', Caret), ParameterExtensions.Unescape(S('a', Caret))); + Assert.Equal("", ParameterExtensions.Unescape("")); + } + + [Fact] + public void EscapingRoundTripsOverTheWholeEscapeSurface() + { + char[] alphabet = [Bs, 'n', 'N', ';', ',', Lf, '\r', Caret, Apostrophe, Quote, 'a', 'x', '1', ' ']; + foreach (var input in AllStrings(alphabet, 3)) + { + if (input.Length == 0) + { + continue; + } + var textRound = EscapingExtensions.UnescapeText(EscapingExtensions.EscapeText(input)); + Assert.Equal(input, textRound); + + var caretRound = ParameterExtensions.Unescape(CaretEscape(input)); + Assert.Equal(input, caretRound); + } + } + + [Fact] + public void TextPropertyValueRoundTripsEscapedBackslash() + { + var original = S(Bs, 'n', Bs, 'N', Lf, Bs, ';', Bs, ','); + var raw = new CalendarObject("SUMMARY", EscapingExtensions.EscapeText(original), []); + var prop = new TextProperty(raw); + Assert.Equal(original, prop.Value); + } + + [Fact] + public void ParserUnescapesCaretEscapedParameterValue() + { + var builder = new CalendarBuilder(); + var content = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + + "X-PROP;X-PARAM=^^n;X-OTHER=^^':v\r\nEND:VCALENDAR\r\n"; + Assert.True(builder.Parser.TryParse(content, out var vcal)); + Assert.NotNull(vcal); + var prop = vcal.Properties.First(p => p.Name == "X-PROP"); + Assert.Equal(S(Caret, 'n'), prop.Raw.Parameters.First(p => p.Name == "X-PARAM").Value); + Assert.Equal(S(Caret, Apostrophe), prop.Raw.Parameters.First(p => p.Name == "X-OTHER").Value); + } + + private static IEnumerable AllStrings(char[] alphabet, int maxLength) + { + yield return ""; + for (var length = 1; length <= maxLength; length++) + { + foreach (var combo in AllCombos(alphabet, length)) + { + yield return new string(combo); + } + } + } + + private static IEnumerable AllCombos(char[] alphabet, int length) + { + if (length == 1) + { + foreach (var c in alphabet) + { + yield return [c]; + } + yield break; + } + foreach (var prefix in AllCombos(alphabet, length - 1)) + { + foreach (var c in alphabet) + { + var combo = new char[length]; + Array.Copy(prefix, combo, length - 1); + combo[length - 1] = c; + yield return combo; + } + } + } +} diff --git a/VSyntaxReader/Properties/EscapingExtensions.cs b/VSyntaxReader/Properties/EscapingExtensions.cs index 2c33dd2..8f830f9 100644 --- a/VSyntaxReader/Properties/EscapingExtensions.cs +++ b/VSyntaxReader/Properties/EscapingExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text; namespace Calendare.VSyntaxReader.Properties; @@ -27,13 +28,34 @@ public static class EscapingExtensions { return null; } - var unescaped = value; // https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11 - unescaped = unescaped.Replace("\\\\", "\\"); - unescaped = unescaped.Replace("\\n", "\n"); - unescaped = unescaped.Replace("\\N", "\n"); - unescaped = unescaped.Replace("\\;", ";"); - unescaped = unescaped.Replace("\\,", ","); - return unescaped; + var unescaped = new StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '\\' && i + 1 < value.Length) + { + var next = value[++i]; + switch (next) + { + case 'n': + case 'N': + unescaped.Append('\n'); + break; + case '\\': + case ';': + case ',': + unescaped.Append(next); + break; + default: + unescaped.Append('\\').Append(next); + break; + } + } + else + { + unescaped.Append(value[i]); + } + } + return unescaped.ToString(); } } diff --git a/VSyntaxReader/Properties/PropertyParameterExtensions.cs b/VSyntaxReader/Properties/PropertyParameterExtensions.cs index 06e6c08..6470893 100644 --- a/VSyntaxReader/Properties/PropertyParameterExtensions.cs +++ b/VSyntaxReader/Properties/PropertyParameterExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text; using LinkDotNet.StringBuilder; namespace Calendare.VSyntaxReader.Properties; @@ -103,10 +104,34 @@ public static void AmendParameter(this IProperty property, string paramName, str public static string Unescape(string value) { - var escapedParam = value; - escapedParam = escapedParam.Replace("^'", "\""); - escapedParam = escapedParam.Replace("^n", "\n"); - escapedParam = escapedParam.Replace("^^", "^"); - return escapedParam; + // https://datatracker.ietf.org/doc/html/rfc6868#section-3 + var unescaped = new StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '^' && i + 1 < value.Length) + { + var next = value[++i]; + switch (next) + { + case '^': + unescaped.Append('^'); + break; + case 'n': + unescaped.Append('\n'); + break; + case '\'': + unescaped.Append('"'); + break; + default: + unescaped.Append('^').Append(next); + break; + } + } + else + { + unescaped.Append(value[i]); + } + } + return unescaped.ToString(); } } From f68e184d6f3dd2d6fa56927999e6fe362931b345 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Fri, 31 Jul 2026 21:07:25 +0200 Subject: [PATCH 2/2] chore: refresh lock file for republished Microsoft.NET.ILLink.Tasks nuget.org republished Microsoft.NET.ILLink.Tasks 10.0.10, so the pinned contentHash in VSyntaxReader/packages.lock.json no longer matches the downloaded package and 'dotnet restore --locked-mode' fails with NU1403 on a fresh machine. Regenerated by dotnet restore. --- VSyntaxReader/packages.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VSyntaxReader/packages.lock.json b/VSyntaxReader/packages.lock.json index 3ac356e..9c485ff 100644 --- a/VSyntaxReader/packages.lock.json +++ b/VSyntaxReader/packages.lock.json @@ -18,7 +18,7 @@ "type": "Direct", "requested": "[10.0.10, )", "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "contentHash": "USPlGK4SzGfR2gy8h4lZ54e9hHBJpPiaPn86f1jEuRmu92/T+DGiexDbPpRBQzNVSJtCW6VR0q2VslLbchrrnA==" }, "NodaTime": { "type": "Direct",