fix(themes): leave room for a kept theme to grow when rewritten
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:
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()
|
||||
|
||||
Reference in new issue
Block a user