test: pin the sanitizer allowlist and resource-policy boundaries #49

Merged
james.maes merged 5 commits from test/pin-untrusted-input-boundaries into main 2026-11-11 00:32:29 +00:00
12 changed files with 1149 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Threat | Mitigation and evidence |
| --- | --- |
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser malicious-script and reader acceptance passed on 77dd04d |
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access. Reserved device names and forbidden characters are one written-out rule (`WindowsFileNames`), independent of the running Windows version: every device name in any letter case, with any extension, and with spaces before the extension |
| Junction/symlink escape or check/reopen race | Check normalized physical path on opened image handle; read same handle; real junction rejection test passes |
| Remote image tracking / resource exfiltration | No-referrer header, no document networking; actual listening-server browser denial passed on 77dd04d |
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
@@ -19,12 +19,6 @@ public sealed class DocumentResourcePolicy
".md", ".markdown", ".mdown", ".mkd", ".txt",
};
private static readonly HashSet<string> s_deviceNames = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³",
};
private readonly string _prefix;
@@ -38,6 +32,16 @@ public sealed class DocumentResourcePolicy
/// <summary>The image file types served to the document surface.</summary>
internal static IReadOnlyCollection<string> ImageExtensions => s_imageExtensions;
/// <summary>The document file types a link may open in a new reader.</summary>
internal static IReadOnlyCollection<string> DocumentExtensions => s_documentExtensions;
/// <summary>The only top-level document URI the browser host may serve.</summary>
public Uri DocumentUri { get; }
@@ -93,7 +97,7 @@ public sealed class DocumentResourcePolicy
private static bool IsSafeSegment(string segment)
{
return segment.Length > 0 && segment is not ("." or "..") && !segment.EndsWith(' ') && !segment.EndsWith('.') &&
!segment.Any(character => char.IsControl(character) || "<>:\\|?*".Contains(character, StringComparison.Ordinal)) &&
!s_deviceNames.Contains(segment.Split('.')[0]);
!segment.Any(character => char.IsControl(character) || WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal)) &&
!WindowsFileNames.IsReservedDeviceName(segment);
}
}
@@ -59,6 +59,11 @@ public sealed class MarkdownRenderer
/// <summary>The configured sanitizer, for comparing its allowlists with an independent list.</summary>
internal HtmlSanitizer Policy => _sanitizer;
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
public string Render(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide) => Render(markdown, frontmatter, null);
@@ -6,6 +6,7 @@
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -15,8 +16,24 @@ 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>
/// 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"];
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
private static readonly string s_baseStyles = LoadBaseStyles();
@@ -59,7 +76,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 +120,7 @@ public sealed class ReaderTheme
}
source["colors"]![key] = value?.Trim();
}
return ParseCompatible(source.ToJsonString());
return ParseCompatible(source.ToJsonString(s_compact));
}
@@ -150,13 +167,14 @@ 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 var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
using JsonDocument document = ParseDocument(json);
JsonElement root = document.RootElement;
RequireObject(root);
RequireReadableText(root);
int schemaVersion = 1;
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out schemaVersion) || schemaVersion < 1 || !allowNewer && schemaVersion != 1))
{
@@ -278,6 +296,68 @@ 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
/// unpaired surrogate escape is valid JSON syntax with no string value, and a repeated
/// property name has no single value; both are invalid themes.
/// </summary>
private static void RequireReadableText(JsonElement root)
{
try
{
var pending = new Stack<JsonElement>();
pending.Push(root);
while(pending.TryPop(out JsonElement element))
{
if(element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach(JsonProperty property in element.EnumerateObject())
{
if(!names.Add(property.Name))
{
throw new JsonException("The theme repeats a property: " + property.Name);
}
pending.Push(property.Value);
}
}
else if(element.ValueKind == JsonValueKind.Array)
{
foreach(JsonElement item in element.EnumerateArray())
{
pending.Push(item);
}
}
else if(element.ValueKind == JsonValueKind.String)
{
_ = element.GetString();
}
}
}
catch(InvalidOperationException exception)
{
throw new JsonException("The theme contains text that cannot be decoded.", exception);
}
}
/// <summary>Prevents wrong-shaped JSON from reaching property accessors.</summary>
private static void RequireObject(JsonElement element)
{
@@ -33,14 +33,13 @@ public static class ThemeJson
public static string SuggestedFileName(ReaderTheme theme)
{
ArgumentNullException.ThrowIfNull(theme);
string stem = string.Concat(theme.Name.Select(character => "<>:\"/\\|?*".Contains(character, StringComparison.Ordinal) ? '_' : character))
string stem = string.Concat(theme.Name.Select(character => WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal) ? '_' : character))
.Replace(' ', '-').ToLowerInvariant().Trim('.', '-');
if(stem.Length == 0)
{
stem = "theme";
}
string device = stem.Split('.')[0];
if(device is "con" or "prn" or "aux" or "nul" || device.Length == 4 && (device.StartsWith("com", StringComparison.Ordinal) || device.StartsWith("lpt", StringComparison.Ordinal)) && device[3] is >= '1' and <= '9' or '¹' or '²' or '³')
if(WindowsFileNames.IsReservedDeviceName(stem))
{
stem = "theme-" + stem;
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// The Windows file-name rules this application relies on, written out instead of asked of the
/// running platform: the characters a name cannot contain and the names that address a device.
/// </summary>
public static class WindowsFileNames
{
/// <summary>Printable characters that cannot appear in a Windows file or folder name. Control characters are also forbidden; callers refuse those themselves.</summary>
public const string InvalidCharacters = "<>:\"/\\|?*";
private static readonly HashSet<string> s_devices = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
};
/// <summary>
/// Whether a single name addresses a device under the legacy Win32 rule: any letter case, and
/// ignoring everything after the first dot and any spaces before it, so <c>nul.txt</c> and
/// <c>COM1 .log</c> count. Current Windows versions resolve fewer of these spellings to a
/// device; the rule is applied in full on every version so the answer never depends on it.
/// </summary>
public static bool IsReservedDeviceName(string name)
{
ArgumentNullException.ThrowIfNull(name);
int dot = name.IndexOf('.', StringComparison.Ordinal);
return s_devices.Contains((dot < 0 ? name : name[..dot]).TrimEnd(' '));
}
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Each resource-policy check is exercised alone: a request that differs from a valid one in a
/// single component must be denied by that component's check, not by an unrelated one.
/// </summary>
public sealed class DocumentResourcePolicyBoundaryTests
{
/// <summary>The valid baseline every single-component variation below is derived from.</summary>
[Fact]
public void GetRelativePath_OwnDocumentRequest_IsAccepted()
{
var policy = new DocumentResourcePolicy();
Assert.Equal("image.png", policy.GetImageRelativePath(Vary(policy, "image.png", _ => { })));
Assert.Equal("child.md", policy.GetDocumentRelativePath(Vary(policy, "child.md", _ => { })));
}
/// <summary>Plain HTTP on the same host, port and path is not the document's origin.</summary>
[Fact]
public void GetRelativePath_OtherScheme_IsDenied()
{
var policy = new DocumentResourcePolicy();
Uri image = Vary(policy, "image.png", builder =>
{
builder.Scheme = "http";
builder.Port = 443;
});
Assert.Equal(443, image.Port);
Assert.Null(policy.GetImageRelativePath(image));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
{
builder.Scheme = "http";
builder.Port = 443;
})));
}
/// <summary>Another port on the same host is another origin.</summary>
[Fact]
public void GetRelativePath_OtherPort_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Port = 444)));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Port = 8443)));
}
/// <summary>Credentials in the request never reach a resource.</summary>
[Fact]
public void GetRelativePath_UserInformation_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.UserName = "user")));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
{
builder.UserName = "user";
builder.Password = "secret";
})));
}
/// <summary>Another host is denied, including a subdomain of the viewer's host and a host that merely starts with it.</summary>
[Theory]
[InlineData("example.com")]
[InlineData("sub.viewer.markdownviewr.invalid")]
[InlineData("viewer.markdownviewr.invalid.example.com")]
public void GetRelativePath_OtherHost_IsDenied(string host)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Host = host)));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Host = host)));
}
/// <summary>The served image types and the openable document types are exactly these sets.</summary>
[Fact]
public void Extensions_ConfiguredSets_AreExactlyTheListedTypes()
{
Assert.Equal([".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"], DocumentResourcePolicy.ImageExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal([".markdown", ".md", ".mdown", ".mkd", ".txt"], DocumentResourcePolicy.DocumentExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
}
/// <summary>The document's own path prefix is matched exactly; another letter case is another document.</summary>
[Fact]
public void GetRelativePath_PrefixInAnotherLetterCase_IsDenied()
{
var policy = new DocumentResourcePolicy();
string shouted = policy.DocumentUri.AbsoluteUri.Replace("/documents/", "/DOCUMENTS/", StringComparison.Ordinal);
Assert.NotEqual(policy.DocumentUri.AbsoluteUri, shouted, StringComparer.Ordinal);
Assert.Null(policy.GetImageRelativePath(new Uri(new Uri(shouted), "image.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(new Uri(shouted), "child.md")));
Assert.Null(policy.GetHeadingIndex(new Uri(shouted + "#mv-heading-0"), 3));
Assert.Equal(0, policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + "#mv-heading-0"), 3));
}
/// <summary>Image types are served in any letter case; documents, pages, scripts and executables are not images.</summary>
[Theory]
[InlineData(".png", true)]
[InlineData(".jpg", true)]
[InlineData(".jpeg", true)]
[InlineData(".gif", true)]
[InlineData(".bmp", true)]
[InlineData(".webp", true)]
[InlineData(".svg", true)]
[InlineData(".avif", true)]
[InlineData(".ico", true)]
[InlineData(".PNG", true)]
[InlineData(".md", false)]
[InlineData(".markdown", false)]
[InlineData(".txt", false)]
[InlineData(".html", false)]
[InlineData(".htm", false)]
[InlineData(".js", false)]
[InlineData(".css", false)]
[InlineData(".json", false)]
[InlineData(".pdf", false)]
[InlineData(".tiff", false)]
[InlineData(".exe", false)]
[InlineData(".lnk", false)]
[InlineData(".url", false)]
[InlineData("", false)]
public void GetImageRelativePath_Extension_ServesOnlyImageTypes(string extension, bool served)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(served ? "file" + extension : null, policy.GetImageRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
}
/// <summary>Document types open in a new reader in any letter case; pages, images, scripts and executables do not.</summary>
[Theory]
[InlineData(".md", true)]
[InlineData(".markdown", true)]
[InlineData(".mdown", true)]
[InlineData(".mkd", true)]
[InlineData(".txt", true)]
[InlineData(".MD", true)]
[InlineData(".html", false)]
[InlineData(".htm", false)]
[InlineData(".png", false)]
[InlineData(".svg", false)]
[InlineData(".json", false)]
[InlineData(".pdf", false)]
[InlineData(".exe", false)]
[InlineData(".bat", false)]
[InlineData(".cmd", false)]
[InlineData(".ps1", false)]
[InlineData(".lnk", false)]
[InlineData(".url", false)]
[InlineData(".mdx", false)]
[InlineData("", false)]
public void GetDocumentRelativePath_Extension_OpensOnlyDocumentTypes(string extension, bool opened)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(opened ? "file" + extension : null, policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
}
/// <summary>Each character Windows forbids in a name is refused on its own, for images and documents.</summary>
[Theory]
[InlineData("%3C")]
[InlineData("%3E")]
[InlineData("%3A")]
[InlineData("%22")]
[InlineData("%5C")]
[InlineData("%7C")]
[InlineData("%3F")]
[InlineData("%2A")]
public void GetRelativePath_ForbiddenCharacter_IsDenied(string encoded)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "images/a" + encoded + "b.png")));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "im" + encoded + "ages/ab.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/a" + encoded + "b.md")));
}
/// <summary>The generated heading fragment is matched exactly; another letter case is not a heading target.</summary>
[Theory]
[InlineData("#MV-HEADING-0")]
[InlineData("#Mv-Heading-0")]
public void GetHeadingIndex_OtherSpelling_IsNotAHeadingTarget(string fragment)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + fragment), 3));
}
/// <summary>Builds this document's own resource request and changes exactly one component of it.</summary>
private static Uri Vary(DocumentResourcePolicy policy, string file, Action<UriBuilder> change)
{
var builder = new UriBuilder(new Uri(policy.DocumentUri, file));
change(builder);
return builder.Uri;
}
}
@@ -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);
}
@@ -0,0 +1,220 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using Ganss.Xss;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// The elements, attributes and link schemes authored HTML may keep. The configured policy is
/// compared with the lists below as sets, so any change to it has to be made here as well, and
/// the rendering tests show the policy doing what the lists say.
/// </summary>
public sealed class SanitizerAllowlistTests
{
private static readonly string[] s_allowedTags =
[
"a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "ins", "kbd", "li", "mark", "ol", "p", "pre",
"s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul",
];
private static readonly string[] s_otherTags =
[
"acronym", "address", "applet", "area", "article", "aside", "audio", "base", "basefont", "bdi", "bdo", "bgsound", "big",
"blink", "button", "canvas", "center", "cite", "data", "datalist", "dfn", "dialog", "dir", "embed", "fencedframe",
"fieldset", "figcaption", "figure", "font", "footer", "form", "header", "hgroup", "iframe", "keygen", "label", "legend",
"link", "listing", "main", "map", "marquee", "math", "menu", "menuitem", "meta", "meter", "nav", "nobr", "noembed",
"noframes", "noscript", "object", "optgroup", "option", "output", "param", "picture", "plaintext", "portal", "progress",
"q", "rb", "rp", "rt", "rtc", "ruby", "samp", "script", "search", "section", "select", "selectedcontent", "slot", "source",
"strike", "style", "svg", "template", "textarea", "time", "title", "track", "tt", "u", "var", "video", "wbr", "xmp",
"custom-element",
];
private static readonly string[] s_allowedAttributes =
[
"align", "alt", "checked", "class", "colspan", "disabled", "height", "href", "rowspan", "src", "title", "type", "width",
];
private static readonly string[] s_otherAttributes =
[
"accesskey", "action", "allow", "as", "autocapitalize", "autofocus", "autoplay", "background", "bgcolor", "border",
"cellpadding", "cellspacing", "charset", "cite", "color", "content", "contenteditable", "controls", "coords",
"crossorigin", "data", "data-mv-body", "data-mv-source-fixed", "data-x", "datetime", "dir", "download", "draggable",
"enctype", "enterkeyhint", "face", "for", "form", "formaction", "formmethod", "formtarget", "headers", "hidden",
"http-equiv", "id", "inert", "inputmode", "integrity", "is", "ismap", "itemprop", "label", "lang", "list", "loading",
"longdesc", "loop", "lowsrc", "manifest", "max", "maxlength", "media", "method", "min", "multiple", "muted", "name",
"nonce", "novalidate", "nowrap", "onclick", "onerror", "onfocus", "onload", "onmouseover", "onpointerdown", "ontoggle",
"onwheel", "open", "part", "pattern", "ping", "placeholder", "popover", "popovertarget", "poster", "preload", "readonly",
"referrerpolicy", "rel", "required", "reversed", "role", "sandbox", "scope", "selected", "shape", "size", "sizes", "slot",
"span", "spellcheck", "srcdoc", "srclang", "srcset", "start", "step", "tabindex", "target", "translate", "usemap",
"valign", "value", "wrap", "xlink:href", "xml:lang", "xmlns", "aria-label",
];
/// <summary>The policy the renderer configures is exactly these elements, attributes and schemes, with no styling or data attributes.</summary>
[Fact]
public void Constructor_ConfiguredPolicy_AllowsExactlyTheListedMarkup()
{
HtmlSanitizer policy = new MarkdownRenderer().Policy;
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), policy.AllowedTags.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(s_allowedAttributes.Order(StringComparer.Ordinal), policy.AllowedAttributes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(["http", "https", "mailto"], policy.AllowedSchemes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Empty(policy.AllowedCssProperties);
Assert.Empty(policy.AllowedAtRules);
Assert.False(policy.AllowDataAttributes);
Assert.False(policy.AllowCssCustomProperties);
}
/// <summary>Every allowed element survives in a document that nests each one where HTML permits it.</summary>
[Fact]
public void Render_EveryAllowedElement_SurvivesSanitation()
{
const string Markup = """
<div><p><a href="x.md">a</a><abbr>b</abbr><b>c</b><br><code>d</code><del>e</del><em>f</em><i>g</i><img src="i.png" alt="h"><input type="checkbox"><ins>j</ins><kbd>k</kbd><mark>l</mark><s>m</s><small>n</small><span>o</span><strong>p</strong><sub>q</sub><sup>r</sup></p><blockquote>s</blockquote><hr><pre>t</pre><h1>1</h1><h2>2</h2><h3>3</h3><h4>4</h4><h5>5</h5><h6>6</h6><dl><dt>u</dt><dd>v</dd></dl><details><summary>w</summary>x</details><ul><li>y</li></ul><ol><li>z</li></ol><table><caption>c</caption><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table></div>
""";
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), ElementNames(document), StringComparer.Ordinal);
}
/// <summary>No other element survives between allowed elements; its text may remain but the element does not.</summary>
[Fact]
public void Render_EveryOtherElement_IsRemoved()
{
var renderer = new MarkdownRenderer();
Assert.All(s_otherTags, tag =>
{
using IHtmlDocument document = Parse(renderer.Render($"<div><{tag} title=\"t\">inner</{tag}><span>after</span></div>"));
Assert.Empty(ElementNames(document).Except(["div", "span"], StringComparer.Ordinal));
});
Assert.Empty(s_allowedTags.Intersect(s_otherTags, StringComparer.Ordinal));
}
/// <summary>Table parts that only exist inside a table, and are not allowed, are removed there while the allowed parts stay.</summary>
[Fact]
public void Render_TablePartsOutsideTheAllowlist_AreRemovedInsideATable()
{
const string Markup = "<table><colgroup><col span=\"2\"></colgroup><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>";
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
Assert.Empty(document.Body!.QuerySelectorAll("colgroup,col,tfoot"));
Assert.NotNull(document.Body.QuerySelector("table > thead > tr > th"));
Assert.NotNull(document.Body.QuerySelector("table > tbody > tr > td"));
}
/// <summary>Exactly the allowed attributes survive on an allowed element; identity, behaviour, styling and data attributes do not.</summary>
[Fact]
public void Render_EveryAttribute_KeepsExactlyTheAllowedOnes()
{
string attributes = string.Join(' ', s_allowedAttributes.Concat(s_otherAttributes).Select(name => string.Equals(name, "class", StringComparison.Ordinal) ? "class=\"frontmatter\"" : name + "=\"1\"")) + " style=\"color:red\"";
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<div><span {attributes}>text</span></div>"));
IElement span = Assert.Single(document.Body!.QuerySelectorAll("span"));
Assert.Equal(s_allowedAttributes, span.Attributes.Select(attribute => attribute.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Empty(s_allowedAttributes.Intersect(s_otherAttributes, StringComparer.Ordinal));
}
/// <summary>Only the fixed styling classes and bounded code-language classes survive, spelled exactly.</summary>
[Theory]
[InlineData("frontmatter fm-key fm-value task-list task-list-item", "frontmatter fm-key fm-value task-list task-list-item")]
[InlineData("language-csharp hljs keyword", "language-csharp")]
[InlineData("LANGUAGE-csharp Language-go language-rust", "language-rust")]
[InlineData("Frontmatter FM-KEY", null)]
[InlineData("language- language-a;b language-<x>", null)]
public void Render_ClassNames_KeepsOnlyTheStylingClasses(string authored, string? kept)
{
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<span class=\"{authored}\">text</span>"));
Assert.Equal(kept, Assert.Single(document.Body!.QuerySelectorAll("span")).GetAttribute("class"));
}
/// <summary>A code-language class is kept up to 80 characters and dropped one character past that.</summary>
[Fact]
public void Render_CodeLanguageClassAtAndPastTheLengthLimit_IsBoundedExactly()
{
string atLimit = "language-" + new string('x', 71);
Assert.Equal(80, atLimit.Length);
using IHtmlDocument kept = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}\">text</span>"));
Assert.Equal(atLimit, Assert.Single(kept.Body!.QuerySelectorAll("span")).GetAttribute("class"));
using IHtmlDocument dropped = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}x\">text</span>"));
Assert.Null(Assert.Single(dropped.Body!.QuerySelectorAll("span")).GetAttribute("class"));
}
/// <summary>The attribute that carries source positions is allowed only during a source-aware render, whether that render finishes or fails.</summary>
[Fact]
public void RenderWithSources_AfterTheRender_NoLongerAllowsTheSourceAttribute()
{
var renderer = new MarkdownRenderer();
var sources = new DocumentSourceMap("# Title");
Assert.Contains("Title", renderer.RenderWithSources("# Title", FrontmatterMode.Hide, sources), StringComparison.Ordinal);
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
Assert.Throws<ArgumentOutOfRangeException>(() => renderer.RenderWithSources("# Title", (FrontmatterMode)99, sources));
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
}
/// <summary>Only web and mail links keep a target; every other scheme, including ones Windows would hand to another application, is removed.</summary>
[Theory]
[InlineData("http://example.com/a", true)]
[InlineData("https://example.com/a", true)]
[InlineData("mailto:someone@example.com", true)]
[InlineData("child.md", true)]
[InlineData("#section", true)]
[InlineData("HTTPS://example.com/a", true)]
[InlineData("ftp://example.com/a", false)]
[InlineData("file:///C:/private.txt", false)]
[InlineData("FILE:///C:/private.txt", false)]
[InlineData("javascript:run()", false)]
[InlineData("vbscript:run()", false)]
[InlineData("data:text/html,<p>x</p>", false)]
[InlineData("blob:https://example.com/1", false)]
[InlineData("about:blank", false)]
[InlineData("ws://example.com", false)]
[InlineData("tel:5551234", false)]
[InlineData("sms:5551234", false)]
[InlineData("ms-settings:defaultapps", false)]
[InlineData("ms-msdt:/id", false)]
[InlineData("ms-word:ofe|u|https://example.com/a.docx", false)]
[InlineData("search-ms:query=x", false)]
[InlineData("shell:startup", false)]
[InlineData("res://ieframe.dll/x", false)]
[InlineData("view-source:https://example.com", false)]
[InlineData("onenote:x", false)]
public void Render_LinkScheme_KeepsOnlyWebAndMailTargets(string target, bool kept)
{
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<a href=\"{target}\">link</a>"));
IElement link = Assert.Single(document.Body!.QuerySelectorAll("a"));
Assert.Equal(kept, link.HasAttribute("href"));
}
/// <summary>The distinct element names in the sanitized body, in ordinal order.</summary>
private static string[] ElementNames(IHtmlDocument document)
=> document.Body!.QuerySelectorAll("*").Select(element => element.LocalName).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
/// <summary>Parses renderer output the way the browser surface does.</summary>
private static IHtmlDocument Parse(string html) => new HtmlParser().ParseDocument("<!doctype html><html><body>" + html + "</body></html>");
}
@@ -35,6 +35,10 @@ public sealed class SecurityFuzzTests
"[section](#heading) ![image](images/hello%20world.png)\n", "| A | B |\n|---|---|\n| x | y |\n",
"- [x] Task\n> Quote\n", "😀 café\0\t\r\n", "<!-- <code> -->", "</code></pre></div>",
"</style><script>execute()</script>", "&lt;script&gt;literal&lt;/script&gt;", "\"'<>:=[]()\\/!?%",
"<video src='x.mp4' autoplay onplay='execute()'><source src='y.mp4'></video><audio src='x.mp3' controls></audio>",
"<embed src='x.swf'><object data='x.swf'><param name='a' value='b'></object><math><mi xlink:href='javascript:execute()'>x</mi></math>",
"<link rel='stylesheet' href='https://outside.invalid/x.css'><meta http-equiv='refresh' content='0;url=https://outside.invalid'><base href='https://outside.invalid/'>",
"<textarea autofocus onfocus='execute()'>text</textarea><select><option>one</option></select><a href='x.md' target='_blank' name='n' ping='https://outside.invalid'>x</a>",
];
@@ -242,11 +246,12 @@ public sealed class SecurityFuzzTests
/// <summary>Uses the parsed DOM's executable capabilities as the oracle, not the renderer's private allowlist.</summary>
private static void AssertPassive(IHtmlDocument document)
{
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta[http-equiv],audio,video"));
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta,link,audio,video,source,param,textarea,select,option"));
foreach(IElement element in document.QuerySelectorAll("*"))
{
Assert.DoesNotContain(element.Attributes, attribute => attribute.Name.StartsWith("on", StringComparison.OrdinalIgnoreCase));
Assert.False(element.HasAttribute("srcdoc") || element.HasAttribute("srcset") || element.HasAttribute("style"));
Assert.False(element.HasAttribute("target") || element.HasAttribute("ping") || element.HasAttribute("name") || element.HasAttribute("autofocus"));
foreach(string name in new[] { "href", "src" })
{
string? value = element.GetAttribute(name);
@@ -0,0 +1,431 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.Json.Nodes;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using MarkdownViewr.Windows.Core.Documents;
using MarkdownViewr.Windows.Core.Editors;
using MarkdownViewr.Windows.Core.Printing;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Validation of text that comes from a document, a settings file, an imported theme or the
/// browser surface: each check is pinned by an input that only that check rejects.
/// </summary>
public sealed class UntrustedInputValidationTests
{
/// <summary>Metadata keys and values are display text: markup in either is escaped, never parsed.</summary>
[Fact]
public void RenderMetadata_MarkupInKeyAndValue_IsEscapedText()
{
const string Key = "<b>key</b>";
const string Value = "<img src=x onerror=run()> & \"quoted\"";
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(Key + ": " + Value));
Assert.Equal(Key, Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
Assert.Equal(Value, Assert.Single(parsed.QuerySelectorAll("td.fm-value")).TextContent);
Assert.Empty(parsed.QuerySelectorAll("b,img"));
}
/// <summary>A line that starts with its colon still has a key cell, so the table keeps two columns.</summary>
[Fact]
public void RenderMetadata_LeadingColon_KeepsAnEmptyKeyCell()
{
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(": value"));
Assert.Equal("", Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
IElement value = Assert.Single(parsed.QuerySelectorAll("td.fm-value"));
Assert.Equal("value", value.TextContent);
Assert.False(value.HasAttribute("colspan"));
}
/// <summary>The private source marker is new for every render, so an authored attribute cannot predict it.</summary>
[Fact]
public void AttributeName_EachSourceMap_IsUnpredictable()
{
string first = new DocumentSourceMap("# Title").AttributeName;
string second = new DocumentSourceMap("# Title").AttributeName;
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", first);
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", second);
Assert.NotEqual(first, second, StringComparer.Ordinal);
}
/// <summary>Raw HTML headings map to their source at every level, including the sixth.</summary>
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(6)]
public void SourceHeadings_RawHtmlHeadingLevel_MapsItsSource(int level)
{
string heading = $"<h{level}>Raw</h{level}>";
string source = "Intro\n\n" + heading + "\n\nAfter\n";
var outline = new DocumentOutline(source);
Assert.Equal(level, Assert.Single(outline.Headings).Level);
DocumentSourceHeading mapped = Assert.Single(outline.SourceHeadings);
Assert.Equal(heading, source[mapped.Start..mapped.End]);
}
/// <summary>A level the HTML standard does not define is not a heading and gets no source mapping.</summary>
[Fact]
public void SourceHeadings_RawHtmlLevelSeven_IsNotAHeading()
{
var outline = new DocumentOutline("<h7>Not a heading</h7>\n");
Assert.Empty(outline.Headings);
Assert.Empty(outline.SourceHeadings);
}
/// <summary>Loading repairs every stored list and object, not only the ones the window happens to read first.</summary>
[Fact]
public void Sanitize_CorruptPrintEditorsAndThemeLists_RepairsEachProperty()
{
var valid = new EditorConfiguration(Guid.NewGuid(), "Editor", @"C:\Editor\editor.exe");
var settings = new AppSettings
{
Print = new PrintOptions(Theme: "Bad\u0001Theme", PaddingPixels: 5000),
ConfiguredEditors = [valid, new EditorConfiguration(Guid.NewGuid(), "Script", @"C:\Editor\run.cmd"), new EditorConfiguration(Guid.Empty, "Empty", @"C:\Editor\other.exe")],
DisabledThemes = ["Nord", "Nord", "Bad\u0001Name", ""],
DeletedBuiltInThemes = ["Dracula", "Dracula", "Bad\u0002Name"],
ThemeOrder = ["Nord", "Nord", "Bad\u0003Name"],
DocumentTheme = "Bad\u0001Theme",
};
settings.Sanitize();
Assert.Equal("Clean Printing", settings.Print.Theme);
Assert.Equal(96, settings.Print.PaddingPixels);
Assert.Equal([valid], settings.ConfiguredEditors);
Assert.Equal(valid.Executable, settings.ExternalEditor);
Assert.Equal(["Nord"], settings.DisabledThemes);
Assert.Equal(["Dracula"], settings.DeletedBuiltInThemes);
Assert.Equal(["Nord"], settings.ThemeOrder);
Assert.Equal("Catppuccin Mocha", settings.DocumentTheme);
}
/// <summary>A missing print object, as an older file or a hand edit leaves it, becomes the defaults.</summary>
[Fact]
public void Sanitize_MissingPrintObject_UsesDefaults()
{
var settings = new AppSettings { Print = null! };
settings.Sanitize();
Assert.Equal(new PrintOptions(), settings.Print);
}
/// <summary>Editing a theme cannot silently raise it to a newer schema; that needs the import confirmation.</summary>
[Fact]
public void SaveEdited_SchemaVersionRaised_RequiresConfirmation()
{
ReaderTheme original = ThemeCatalog.Find(null);
JsonNode raised = JsonNode.Parse(original.Json)!;
raised["schemaVersion"] = 2;
var edited = ReaderTheme.ParseCompatible(raised.ToJsonString());
Assert.Equal(1, original.SchemaVersion);
Assert.Equal(2, edited.SchemaVersion);
Assert.Throws<InvalidOperationException>(() => ThemeManagement.SaveEdited(new AppSettings(), original, edited));
}
/// <summary>An already confirmed newer-schema theme stays editable at its own version.</summary>
[Fact]
public void SaveEdited_SameNewerSchema_IsSaved()
{
JsonNode newer = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
newer["schemaVersion"] = 2;
newer["name"] = "Owned future";
var theme = ReaderTheme.ParseCompatible(newer.ToJsonString());
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme, approveNewerSchema: true);
AppSettings saved = ThemeManagement.SaveEdited(imported, theme, theme);
Assert.Equal("Owned future", saved.DocumentTheme);
Assert.Equal(2, ThemeManagement.Active(saved).SchemaVersion);
}
/// <summary>A copy of a theme whose name is already at the limit still has a name the theme parser accepts.</summary>
[Fact]
public void CopyTheme_LongestNameTakenTwice_StaysWithinTheNameLimit()
{
ReaderTheme longest = ThemeCatalog.Find(null).Rename(new string('N', 80));
AppSettings settings = ThemeManagement.Import(new AppSettings(), longest);
ReaderTheme firstCopy = ThemeManagement.CopyTheme(settings, longest);
settings = ThemeManagement.Import(settings, firstCopy);
ReaderTheme secondCopy = ThemeManagement.CopyTheme(settings, longest);
Assert.Equal(80, firstCopy.Name.Length);
Assert.Equal(80, secondCopy.Name.Length);
Assert.EndsWith(" (2)", secondCopy.Name, StringComparison.Ordinal);
Assert.Equal(secondCopy.Name, ReaderTheme.ParseCompatible(secondCopy.Json).Name);
}
/// <summary>Editor and document paths are accepted up to the Windows limit and refused one character past it.</summary>
[Fact]
public void IsExecutablePath_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('e', 32767 - 7) + ".exe";
string pastLimit = @"C:\" + new string('e', 32768 - 7) + ".exe";
Assert.Equal(32767, atLimit.Length);
Assert.True(EditorLaunch.IsExecutablePath(atLimit));
Assert.False(EditorLaunch.IsExecutablePath(pastLimit));
string document = @"C:\" + new string('d', 32767 - 6) + ".md";
Assert.Equal(32767, document.Length);
Assert.Equal(document, Assert.Single(EditorLaunch.Create(@"C:\Editor\editor.exe", document).ArgumentList));
Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", document + "x"));
}
/// <summary>Recent paths are kept up to the Windows limit and dropped one character past it.</summary>
[Fact]
public void Sanitize_RecentPathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('d', 32767 - 6) + ".md";
Assert.Equal([atLimit], RecentDocuments.Sanitize([atLimit, atLimit + "x"]));
}
/// <summary>A find term is accepted at its limit and refused one character past it.</summary>
[Fact]
public void Constructor_FindTermAtAndPastTheLimit_IsBoundedExactly()
{
string atLimit = new('q', DocumentFind.MaximumQueryCharacters);
Assert.Equal(atLimit, new DocumentFind(atLimit + " tail", atLimit).Query);
Assert.Throws<ArgumentException>(() => new DocumentFind("source", atLimit + "q"));
}
/// <summary>The viewport result is read up to its size cap and ignored past it, even when it would parse.</summary>
[Fact]
public void FromBrowserResult_ViewportAtAndPastTheSizeCap_IsBoundedExactly()
{
string atCap = "[0.25," + new string(' ', 128 - 11) + "0.75]";
Assert.Equal(128, atCap.Length);
Assert.Equal(0.75, DocumentViewport.FromBrowserResult(atCap).VerticalFraction);
Assert.Equal(default, DocumentViewport.FromBrowserResult(atCap.Replace("[", "[ ", StringComparison.Ordinal)));
}
/// <summary>The scroll-state result is read up to its size cap for the heading count and ignored past it.</summary>
[Theory]
[InlineData(0)]
[InlineData(3)]
public void FromBrowserResult_ScrollStateAtAndPastTheSizeCap_IsBoundedExactly(int headings)
{
int cap = headings * 128 + 512;
string atCap = "[1,2,[],[]" + new string(' ', cap - 11) + "]";
Assert.Equal(cap, atCap.Length);
Assert.NotNull(DocumentScrollState.FromBrowserResult(atCap, headings));
Assert.Null(DocumentScrollState.FromBrowserResult(atCap.Replace("[1,", "[ 1,", StringComparison.Ordinal), headings));
}
/// <summary>The scroll-state result has exactly four parts; a longer array is not the application's own query result.</summary>
[Theory]
[InlineData("[1,2,[]]")]
[InlineData("[1,2,[],[],[]]")]
[InlineData("[1,2,[],[],0]")]
public void FromBrowserResult_ScrollStateWithWrongNumberOfParts_IsRejected(string json)
=> Assert.Null(DocumentScrollState.FromBrowserResult(json, 2));
/// <summary>Print styles are built from repaired options, so a corrupt padding value cannot reach the stylesheet.</summary>
[Theory]
[InlineData(double.NaN, "padding:0px")]
[InlineData(double.PositiveInfinity, "padding:0px")]
[InlineData(5000, "padding:96px")]
[InlineData(-3, "padding:0px")]
public void Build_CorruptPadding_UsesTheRepairedValue(double padding, string expected)
{
ReaderTheme theme = ThemeCatalog.Find(null);
string css = PrintStyles.Build(new PrintOptions(PaddingPixels: padding), theme, [theme], 1);
Assert.Contains(expected, css, StringComparison.Ordinal);
Assert.DoesNotContain("NaN", css, StringComparison.Ordinal);
Assert.DoesNotContain("Infinity", css, StringComparison.Ordinal);
Assert.DoesNotContain("∞", css, StringComparison.Ordinal);
}
/// <summary>
/// Text the JSON reader cannot decode, an unpaired surrogate escape, makes the theme invalid
/// wherever it appears: in a value the theme reads, in a property name, or in a value this
/// version ignores but would later re-serialize. So does a repeated property name.
/// </summary>
[Theory]
[InlineData("\"name\": \"Nord\"", "\"name\": \"\\uD83D\"")]
[InlineData("\"background\": \"#2e3440\"", "\"background\": \"\\uD800\"")]
[InlineData("\"body\": \"System\"", "\"body\": \"A\\uDC00B\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"\\uD83D\": 1")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"\\uD83D\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [1, [\"ok\", {\"deep\": \"\\uDFFF\"}]]")]
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"\\uD800x\": \"y\"")]
[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;
Assert.Contains(valid, original, StringComparison.Ordinal);
string json = original.Replace(valid, invalid, 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);
}
/// <summary>
/// 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\", \"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);
Assert.Contains(ThemeManagement.All(imported), item => item.Name.StartsWith("Nord (", StringComparison.Ordinal));
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(imported, theme).Name, StringComparison.Ordinal);
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>
/// 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()
{
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);
}
/// <summary>Only the exact lower-case channel name selects the development identity.</summary>
[Theory]
[InlineData("DEV")]
[InlineData("Dev")]
[InlineData("dev ")]
[InlineData("development")]
public void ForChannel_OtherSpellingOfDev_KeepsTheStableIdentity(string channel)
=> Assert.Equal("markdownviewr-windows", AppIdentity.ForChannel(channel).Id);
/// <summary>A colour that is not six hexadecimal digits after a hash, or a label containing a control character, is refused.</summary>
[Theory]
[InlineData("colors.background", "\";ffffff\"")]
[InlineData("colors.background", "\"#fffffff\"")]
[InlineData("colors.background", "\"#ffffffff\"")]
[InlineData("colors.background", "\"#fffff\"")]
[InlineData("colors.background", "\"ffffff#\"")]
[InlineData("colors.text", "\"#12345g\"")]
[InlineData("name", "\"Theme\\u0001Name\"")]
[InlineData("name", "\"Theme\\u007fName\"")]
[InlineData("fonts.heading", "\"Font\\u0001Name\"")]
[InlineData("fonts.code", "\"Font\\u009bName\"")]
public void Parse_MalformedColorOrLabel_IsRejected(string path, string json)
{
JsonNode root = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent[parts[^1]] = JsonNode.Parse(json);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>The written-out Windows name rules, and the two places that depend on them.</summary>
public sealed class WindowsFileNamesTests
{
private static readonly string[] s_devices =
[
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
];
/// <summary>A device is a device in any letter case, with any extension, and with spaces before the extension or at the end.</summary>
[Fact]
public void IsReservedDeviceName_EveryDeviceSpelling_IsReserved()
{
Assert.All(Spellings(), spelling =>
{
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".png"), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".tar.md"), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " .png"), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " "), spelling);
});
Assert.Equal(96, Spellings().Count());
}
/// <summary>Names that only resemble a device stay ordinary file names; only spaces are ignored before the extension.</summary>
[Theory]
[InlineData("")]
[InlineData("console.md")]
[InlineData("COM10.png")]
[InlineData("COM")]
[InlineData("LPT.txt")]
[InlineData("xCON.png")]
[InlineData("CON-notes.md")]
[InlineData("CON-.md")]
[InlineData("null.md")]
[InlineData(" NUL.png")]
[InlineData("NUL\u00A0.png")]
[InlineData("NUL\t.png")]
[InlineData("readme.NUL")]
[InlineData("COM⁴.png")]
[InlineData("CLOCK$")]
[InlineData("CLOCK$.txt")]
public void IsReservedDeviceName_OrdinaryName_IsNotReserved(string name) => Assert.False(WindowsFileNames.IsReservedDeviceName(name));
/// <summary>A missing name is a contract error, not an ordinary file.</summary>
[Fact]
public void IsReservedDeviceName_Null_RejectsInput() => Assert.Throws<ArgumentNullException>(() => WindowsFileNames.IsReservedDeviceName(null!));
/// <summary>The printable characters Windows forbids in a name; control characters are refused separately by each caller.</summary>
[Fact]
public void InvalidCharacters_Constant_ListsThePrintableForbiddenCharacters() => Assert.Equal("\"*/:<>?\\|", new string(WindowsFileNames.InvalidCharacters.Order().ToArray()));
/// <summary>No image or linked-document request can name a device in any spelling, as the file or as a folder on the way to it.</summary>
[Fact]
public void GetRelativePath_DeviceSegment_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.All(Spellings(), spelling =>
{
string segment = Uri.EscapeDataString(spelling);
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + ".png")));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "%20.png")));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "/picture.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".md")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".tar.md")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/" + segment + "/child.md")));
});
Assert.Equal("console.png", policy.GetImageRelativePath(new Uri(policy.DocumentUri, "console.png")));
Assert.Equal("docs/com10/child.md", policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/com10/child.md")));
}
/// <summary>A theme named after a device, in any case, never becomes the suggested export file name.</summary>
[Fact]
public void SuggestedFileName_EveryDeviceName_IsPrefixed()
{
Assert.All(s_devices, device =>
{
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device)));
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device.ToLowerInvariant())));
Assert.Equal("theme-" + device.ToLowerInvariant() + ".backup.json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device + ".backup")));
});
}
/// <summary>Leading and trailing dots and hyphens are dropped from a suggested name, and a name made only of them falls back to a plain one.</summary>
[Theory]
[InlineData("-Edge-", "edge.json")]
[InlineData(".Dotted.", "dotted.json")]
[InlineData("--", "theme.json")]
[InlineData(" spaced ", "spaced.json")]
[InlineData("-con-", "theme-con.json")]
public void SuggestedFileName_LeadingAndTrailingSeparators_AreTrimmed(string name, string expected)
=> Assert.Equal(expected, ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(name)));
/// <summary>Each device name in upper, lower and mixed case.</summary>
private static IEnumerable<string> Spellings()
=> s_devices.SelectMany(device => new[] { device, device.ToLowerInvariant(), char.ToUpperInvariant(device[0]) + device[1..].ToLowerInvariant() });
}