fix(themes): leave room for a kept theme to grow when rewritten
node / node (push) Successful in 12s
build / scripts (push) Successful in 12s
windows / windows (push) Successful in 2m23s
build / windows (push) Successful in 2m23s

JSON writes emoji, no-break spaces and private-use glyphs as
six-character escapes, so renaming a theme made of them could push it
past the import limit and be refused. The import limit still bounds
the file read; a kept theme now has its own, larger limit, so a theme
that could be imported can always be renamed, copied and edited.

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:27:41 -06:00
1 parent 2fff66cf76
commit 7d3cf921ce
3 files changed
+52 -9

No files matched your search

@@ -16,14 +16,22 @@ namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Validates Darin's version-one theme format before generating passive document CSS.</summary>
public sealed class ReaderTheme
{
/// <summary>Bounds schema input before a native file read allocates the complete JSON text.</summary>
/// <summary>The largest theme file accepted for import; bounds the read before the complete JSON text is allocated.</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.
/// The largest theme the app keeps. Renaming or editing rewrites the whole theme, and JSON
/// writes some characters (emoji, no-break spaces, private-use glyphs) as six-character
/// escapes, so a kept theme can be several times the size of the file it was imported from.
/// The bound leaves room for that, so a theme that could be imported can always be renamed.
/// </summary>
public const int MaximumStoredCharacters = 8 * MaximumCharacters;
/// <summary>
/// How a renamed or edited theme is written back. Quote marks, angle brackets and most
/// non-ASCII letters are written as themselves instead of as six-character escapes, which
/// keeps ordinary CSS and text close to its imported size. 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"];
@@ -159,9 +167,9 @@ public sealed class ReaderTheme
private static ReaderTheme ParseSchema(string json, bool allowNewer)
{
ArgumentNullException.ThrowIfNull(json);
if(json.Length > MaximumCharacters)
if(json.Length > MaximumStoredCharacters)
{
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
throw new JsonException("The theme is too large.");
}
using JsonDocument document = ParseDocument(json);
JsonElement root = document.RootElement;
@@ -124,12 +124,15 @@ public sealed class ReaderThemeTests
/// <summary>Null and oversized input are rejected before parser work.</summary>
/// <summary>Null input is refused, and a theme is kept up to the stored size limit exactly and refused one character past it.</summary>
[Fact]
public void Parse_OutsideSizeContract_RejectsInput()
{
Assert.Throws<ArgumentNullException>(() => ReaderTheme.Parse(null!));
Assert.Throws<JsonException>(() => ReaderTheme.Parse(new string(' ', 128 * 1024 + 1)));
string valid = ThemeCatalog.Find("Nord").Json;
Assert.Equal("Nord", ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters)).Name);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters + 1)));
Assert.Equal(8 * 128 * 1024, ReaderTheme.MaximumStoredCharacters);
}
@@ -349,6 +349,38 @@ public sealed class UntrustedInputValidationTests
/// <summary>
/// JSON writes emoji, no-break and ideographic spaces and private-use glyphs as six-character
/// escapes whatever the encoder. A theme as large as an import may be, made of nothing else,
/// can still be renamed, copied and edited: the stored limit leaves room for the growth.
/// </summary>
[Theory]
[InlineData("😀")]
[InlineData(" ")]
[InlineData(" ")]
[InlineData("")]
public void Rename_ThemeAtTheImportLimitMadeOfEscapedCharacters_IsKept(string character)
{
string original = ThemeCatalog.Find("Nord").Json;
string opening = "\"name\": \"Nord\", \"note\": \"";
int room = ReaderTheme.MaximumCharacters - original.Length - (opening.Length - "\"name\": \"Nord\"".Length) - 1;
string filler = string.Concat(Enumerable.Repeat(character, room / character.Length)).PadRight(room, 'x');
string json = original.Replace("\"name\": \"Nord\"", opening + filler + "\"", StringComparison.Ordinal);
Assert.Equal(ReaderTheme.MaximumCharacters, json.Length);
var theme = ReaderTheme.Parse(json);
ReaderTheme renamed = theme.Rename("A considerably longer name than the one it had");
Assert.InRange(renamed.Json.Length, json.Length + 1, ReaderTheme.MaximumStoredCharacters);
Assert.Equal(renamed.Json.Length, renamed.Rename(renamed.Name).Json.Length);
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), renamed).Name, 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), "body { color: red }");
Assert.Equal("Edited", edited.Name);
var settings = new AppSettings { UserThemes = [edited.Json] };
settings.Sanitize();
Assert.Equal([edited.Json], settings.UserThemes);
}
/// <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()