test: pin the sanitizer allowlist and resource-policy boundaries
node / node (push) Successful in 17s
build / scripts (push) Successful in 17s
windows / windows (push) Successful in 1m50s
build / windows (push) Successful in 1m50s

Widening the sanitizer's element, attribute or scheme allowlist left every
test green, and resource-policy tests rejected their inputs for unrelated
reasons, so removing a single check went unnoticed.

Add an independent exact pin for each allowlist, single-component
variations for origin, file type and forbidden characters, and killing
tests for theme colours and labels, metadata escaping, per-property
settings repair, schema approval and exact size limits. The fuzz corpus
now generates the media, form and metadata elements its oracle forbids.

Reserved Windows device names and forbidden characters become one
written-out rule, WindowsFileNames, used by the resource policy and the
theme export name. It covers every device name in any letter case, with
any extension and with spaces before the extension, plus COM0, LPT0,
CONIN$ and CONOUT$, and a double quote is refused like the other
forbidden characters.

Closes #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 16:49:09 -06:00
1 parent 8763ff3206
commit 8770f075ea
9 files changed
+799 -13

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;
@@ -93,7 +87,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);
}
}
@@ -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,37 @@
/*
* 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>Characters that cannot appear in a Windows file or folder name.</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 in any letter case. Windows ignores everything after
/// the first dot and any spaces before it, so <c>nul.txt</c> and <c>COM1 .log</c> are devices too.
/// </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,195 @@
/*
* 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 Baseline_ValidRequests_AreAccepted()
{
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 OtherScheme_SameHostPortAndPath_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 OtherPort_SameSchemeHostAndPath_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 UserInformation_SameOriginAndPath_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, or the same host in another letter case position such as a subdomain, is denied.</summary>
[Theory]
[InlineData("example.com")]
[InlineData("sub.viewer.markdownviewr.invalid")]
[InlineData("viewer.markdownviewr.invalid.example.com")]
public void OtherHost_SameSchemePortAndPath_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>Exactly these image types are served; 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>Exactly these document types open in a new reader; 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 ForbiddenCharacter_InAnySegment_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")]
[InlineData("#mv-heading-0x")]
[InlineData("#mv-heading-00a")]
[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;
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Pins the exact elements, attributes and link schemes that authored HTML may keep. Widening
/// the renderer's allowlist fails here until this independent list is changed deliberately.
/// </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 =
[
"address", "applet", "area", "article", "aside", "audio", "base", "basefont", "bdi", "bdo", "bgsound", "big", "blink",
"button", "canvas", "center", "cite", "col", "colgroup", "data", "datalist", "dfn", "dialog", "dir", "embed", "fencedframe",
"fieldset", "figcaption", "figure", "font", "footer", "form", "frame", "frameset", "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", "tfoot", "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", "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", "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", "style", "tabindex", "target", "translate", "usemap", "valign", "value",
"wrap", "xlink:href", "xml:lang", "xmlns", "aria-label",
];
/// <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.All(s_allowedTags, tag => Assert.NotNull(document.Body!.QuerySelector(tag)));
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), ElementNames(document), StringComparer.Ordinal);
}
/// <summary>No other element survives, alone or between allowed elements; its text may remain but its capability 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>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\""));
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 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("ftp://example.com/a", 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("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)]
[InlineData("HTTPS://example.com/a", true)]
[InlineData("FILE:///C:/private.txt", 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,303 @@
/*
* 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 SourceMap_EveryRender_UsesAnUnpredictableAttributeName()
{
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 Outline_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 Outline_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 EditorLaunch_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 RecentDocuments_PathAtAndPastTheWindowsLimit_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 DocumentFind_QueryAtAndPastTheLimit_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 DocumentViewport_ResultAtAndPastTheSizeCap_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 DocumentScrollState_ResultAtAndPastTheSizeCap_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 DocumentScrollState_WrongNumberOfParts_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 PrintStyles_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>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 parse error anywhere in a theme is reported as invalid JSON content, the one failure callers handle.</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 ThemeParse_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,107 @@
/*
* 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>Every device name, in each letter case and with the suffixes Windows ignores.</summary>
public static TheoryData<string> DeviceSpellings()
{
var data = new TheoryData<string>();
foreach(string device in s_devices)
{
string mixed = char.ToUpperInvariant(device[0]) + device[1..].ToLowerInvariant();
foreach(string spelling in new[] { device, device.ToLowerInvariant(), mixed })
{
data.Add(spelling);
data.Add(spelling + ".png");
data.Add(spelling + ".tar.md");
data.Add(spelling + " .png");
data.Add(spelling + " ");
}
}
return data;
}
/// <summary>A device is a device whatever its case, extension, or trailing spaces before the extension.</summary>
[Theory]
[MemberData(nameof(DeviceSpellings))]
public void IsReservedDeviceName_DeviceSpelling_IsReserved(string name) => Assert.True(WindowsFileNames.IsReservedDeviceName(name));
/// <summary>Names that only resemble a device stay ordinary file names.</summary>
[Theory]
[InlineData("")]
[InlineData("console.md")]
[InlineData("COM10.png")]
[InlineData("COM")]
[InlineData("LPT.txt")]
[InlineData("xCON.png")]
[InlineData("CON-notes.md")]
[InlineData("null.md")]
[InlineData(" NUL.png")]
[InlineData("readme.NUL")]
[InlineData("COM⁴.png")]
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 characters Windows forbids in a name are exactly these nine.</summary>
[Fact]
public void InvalidCharacters_ListsTheNineForbiddenCharacters() => Assert.Equal("\"*/:<>?\\|", new string(WindowsFileNames.InvalidCharacters.Order().ToArray()));
/// <summary>No image or linked-document request can name a device in any spelling, at any depth.</summary>
[Theory]
[MemberData(nameof(DeviceSpellings))]
public void ResourcePolicy_DeviceSegment_DeniesImageAndDocument(string name)
{
var policy = new DocumentResourcePolicy();
string segment = Uri.EscapeDataString(name.TrimEnd(' '));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + (name.Contains('.', StringComparison.Ordinal) ? "" : ".png"))));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "/picture.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + (name.Contains('.', StringComparison.Ordinal) ? "" : ".md"))));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/" + segment + "/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")));
});
}
}