Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions VSyntaxReader.Tests/PropertiesGeneric/EscapingExtensionsTest.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<char[]> 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;
}
}
}
}
36 changes: 29 additions & 7 deletions VSyntaxReader/Properties/EscapingExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;

namespace Calendare.VSyntaxReader.Properties;

Expand Down Expand Up @@ -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();
}
}
35 changes: 30 additions & 5 deletions VSyntaxReader/Properties/PropertyParameterExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
2 changes: 1 addition & 1 deletion VSyntaxReader/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down