fix(themes): keep renamed themes within the size limit
Renaming, copying and editing a theme wrote every quote mark, angle bracket and non-ASCII character as a six-character escape, so a theme well inside the size limit could grow past it and be refused. Those operations now write characters as themselves. Theme text that is not well-formed UTF-16 is refused as an invalid theme. Tests pin that property names differing only in case, the same name in two objects, an escaped spelling of a repeated name and an undecodable string directly inside an array are each handled as intended. Refs #48 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
419de2c263
commit
2fff66cf76
2 files changed
+76
-6
No files matched your search
@@ -6,6 +6,7 @@
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -17,6 +18,14 @@ public sealed class ReaderTheme
|
||||
{
|
||||
/// <summary>Bounds schema input before a native file read allocates the complete JSON text.</summary>
|
||||
public const int MaximumCharacters = 128 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// How a renamed or edited theme is written back. Characters are written as themselves where
|
||||
/// JSON allows it, so a theme within the size limit stays within it: the default encoder turns
|
||||
/// every quote mark, angle bracket and non-ASCII character into a six-character escape. Theme
|
||||
/// JSON is stored and exported, never placed in a page.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions s_compact = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
private static readonly string[] s_colors = ["background", "text", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "link", "codeBackground", "codeText", "blockquoteBorder", "blockquoteBackground", "highlightBackground", "highlightText"];
|
||||
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
|
||||
private static readonly string s_baseStyles = LoadBaseStyles();
|
||||
@@ -59,7 +68,7 @@ public sealed class ReaderTheme
|
||||
{
|
||||
JsonNode source = JsonNode.Parse(Json)!;
|
||||
source["name"] = name;
|
||||
return ParseCompatible(source.ToJsonString());
|
||||
return ParseCompatible(source.ToJsonString(s_compact));
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +112,7 @@ public sealed class ReaderTheme
|
||||
}
|
||||
source["colors"]![key] = value?.Trim();
|
||||
}
|
||||
return ParseCompatible(source.ToJsonString());
|
||||
return ParseCompatible(source.ToJsonString(s_compact));
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +163,7 @@ public sealed class ReaderTheme
|
||||
{
|
||||
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
|
||||
}
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
using JsonDocument document = ParseDocument(json);
|
||||
JsonElement root = document.RootElement;
|
||||
RequireObject(root);
|
||||
RequireReadableText(root);
|
||||
@@ -279,6 +288,21 @@ public sealed class ReaderTheme
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the theme text as JSON; text that is not well-formed UTF-16 is an invalid theme like any other malformed input.</summary>
|
||||
private static JsonDocument ParseDocument(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
}
|
||||
catch(ArgumentException exception)
|
||||
{
|
||||
throw new JsonException("The theme contains text that cannot be decoded.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks every property name and string in the theme once, including ones this version does
|
||||
/// not read, because a kept theme is later renamed, edited and re-serialized as a whole. An
|
||||
|
||||
@@ -285,6 +285,8 @@ public sealed class UntrustedInputValidationTests
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"name\": \"Nord\"")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"body\": \"Other\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"extra\": {\"a\": 1, \"a\": 2}")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"na\\u006de\": \"Nord\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [\"\\uD83D\"]")]
|
||||
public void Parse_UndecodableTextOrRepeatedProperty_IsRejectedAsAnInvalidTheme(string valid, string invalid)
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
@@ -300,13 +302,14 @@ public sealed class UntrustedInputValidationTests
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A theme that parses can be renamed, copied, edited and imported under a new name, whatever
|
||||
/// extra properties it carries: those operations re-serialize the whole document.
|
||||
/// A theme that parses can be renamed, copied, edited and imported under a new name with its
|
||||
/// extra properties intact: those operations re-serialize the whole document. Names that differ
|
||||
/// only in case, and the same name in two different objects, are not repeats.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_AcceptedThemeWithExtraProperties_SurvivesRenameCopyEditAndImport()
|
||||
{
|
||||
string json = ThemeCatalog.Find("Nord").Json.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"caf\\u00e9 \\ud83d\\ude00\", \"future\": {\"list\": [1, \"two\", null]}", StringComparison.Ordinal);
|
||||
string json = ThemeCatalog.Find("Nord").Json.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"Name\": \"other case\", \"note\": \"caf\\u00e9 \\ud83d\\ude00\", \"future\": {\"name\": \"inner\", \"list\": [1, \"two\", null]}", StringComparison.Ordinal);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
Assert.Equal("Renamed", theme.Rename("Renamed").Name);
|
||||
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme);
|
||||
@@ -315,6 +318,49 @@ public sealed class UntrustedInputValidationTests
|
||||
ReaderTheme edited = theme.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
|
||||
Assert.Equal("Edited", edited.Name);
|
||||
Assert.Contains("caf", edited.Json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"Name\":\"other case\"", edited.Json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"name\":\"inner\"", edited.Json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renaming, copying and editing write characters as themselves, so a theme near the size
|
||||
/// limit whose text is full of quote marks, angle brackets or non-ASCII characters stays
|
||||
/// within the limit instead of growing sixfold and being refused.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a>b+c{font:'x'}")]
|
||||
[InlineData("/* \u6f22\u5b57 caf\u00e9 & <tag> */")]
|
||||
public void Rename_ThemeNearTheSizeLimit_DoesNotGrowPastIt(string fragment)
|
||||
{
|
||||
string css = string.Concat(Enumerable.Repeat(fragment, 60_000 / fragment.Length));
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Contains("\"name\": \"Nord\"", original, StringComparison.Ordinal);
|
||||
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"" + css + "\"", StringComparison.Ordinal);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
ReaderTheme renamed = theme.Rename("Renamed");
|
||||
Assert.True(renamed.Json.Length <= json.Length);
|
||||
Assert.Contains(fragment, renamed.Json, StringComparison.Ordinal);
|
||||
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
|
||||
Assert.True(edited.Json.Length <= json.Length);
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), theme).Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A string that is not well-formed UTF-16 is refused as an invalid theme, and a stored one is dropped, rather than failing as a programming error.</summary>
|
||||
[Fact]
|
||||
public void Parse_TextWithAnUnpairedSurrogate_IsRejectedAsAnInvalidTheme()
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"No\uD83Drd\"", StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"name\": \"Nord\"", json, StringComparison.Ordinal);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
|
||||
var settings = new AppSettings { UserThemes = [json, original] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([original], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user