fix(themes): keep renamed themes within the size limit
node / node (push) Successful in 29s
build / scripts (push) Successful in 29s
windows / windows (push) Successful in 2m19s
build / windows (push) Successful in 2m19s

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:
james.maesandClaude Fable 5.2 committed 2026-11-10 18:14:55 -06:00
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);
}