test: compare the configured allowlists as sets and reject undecodable theme text
node / node (push) Successful in 14s
build / scripts (push) Successful in 13s
windows / windows (push) Successful in 1m52s
build / windows (push) Successful in 1m52s

The element pin missed elements it did not list and five it listed in a
context where the parser never creates them. The sanitizer's configured
elements, attributes and schemes, and the resource policy's file types,
are now compared with independent lists as sets, so any change to them
has to be made in the test as well. Table-only elements are tested inside
a table.

A theme string holding an unpaired surrogate escape made the JSON reader
throw InvalidOperationException, which import and saved-theme repair do
not handle. ReaderTheme now reports it as invalid theme JSON like every
other malformed value.

Also pin the styling-class filter, the exact path prefix and document
comparison, the reserved-name rule's negatives and the export-name
trimming; describe the reserved-name rule as the legacy Win32 rule applied
on every Windows version; and run the device spellings as two tests
instead of 960 theory rows.

Refs #48

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-10 17:18:59 -06:00
1 parent 8770f075ea
commit 6ca8284f1b
8 files changed
+218 -85

No files matched your search

@@ -32,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; }
@@ -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);
@@ -260,7 +260,15 @@ public sealed class ReaderTheme
{
throw new JsonException("The theme is missing a required string: " + name);
}
return value.GetString()!;
try
{
return value.GetString()!;
}
catch(InvalidOperationException exception)
{
// An unpaired surrogate escape is valid JSON syntax that has no string value.
throw new JsonException("The theme contains text that cannot be decoded: " + name, exception);
}
}
@@ -12,7 +12,7 @@ namespace MarkdownViewr.Windows.Core;
/// </summary>
public static class WindowsFileNames
{
/// <summary>Characters that cannot appear in a Windows file or folder name.</summary>
/// <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)
@@ -25,8 +25,10 @@ public static class WindowsFileNames
/// <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.
/// 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)
{
@@ -16,7 +16,7 @@ public sealed class DocumentResourcePolicyBoundaryTests
{
/// <summary>The valid baseline every single-component variation below is derived from.</summary>
[Fact]
public void Baseline_ValidRequests_AreAccepted()
public void GetRelativePath_OwnDocumentRequest_IsAccepted()
{
var policy = new DocumentResourcePolicy();
Assert.Equal("image.png", policy.GetImageRelativePath(Vary(policy, "image.png", _ => { })));
@@ -27,7 +27,7 @@ public sealed class DocumentResourcePolicyBoundaryTests
/// <summary>Plain HTTP on the same host, port and path is not the document's origin.</summary>
[Fact]
public void OtherScheme_SameHostPortAndPath_IsDenied()
public void GetRelativePath_OtherScheme_IsDenied()
{
var policy = new DocumentResourcePolicy();
Uri image = Vary(policy, "image.png", builder =>
@@ -48,7 +48,7 @@ public sealed class DocumentResourcePolicyBoundaryTests
/// <summary>Another port on the same host is another origin.</summary>
[Fact]
public void OtherPort_SameSchemeHostAndPath_IsDenied()
public void GetRelativePath_OtherPort_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Port = 444)));
@@ -59,7 +59,7 @@ public sealed class DocumentResourcePolicyBoundaryTests
/// <summary>Credentials in the request never reach a resource.</summary>
[Fact]
public void UserInformation_SameOriginAndPath_IsDenied()
public void GetRelativePath_UserInformation_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.UserName = "user")));
@@ -72,12 +72,12 @@ public sealed class DocumentResourcePolicyBoundaryTests
/// <summary>Another host, or the same host in another letter case position such as a subdomain, is denied.</summary>
/// <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 OtherHost_SameSchemePortAndPath_IsDenied(string host)
public void GetRelativePath_OtherHost_IsDenied(string host)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Host = host)));
@@ -86,7 +86,32 @@ public sealed class DocumentResourcePolicyBoundaryTests
/// <summary>Exactly these image types are served; documents, pages, scripts and executables are not images.</summary>
/// <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)]
@@ -120,7 +145,7 @@ public sealed class DocumentResourcePolicyBoundaryTests
/// <summary>Exactly these document types open in a new reader; pages, images, scripts and executables do not.</summary>
/// <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)]
@@ -160,7 +185,7 @@ public sealed class DocumentResourcePolicyBoundaryTests
[InlineData("%7C")]
[InlineData("%3F")]
[InlineData("%2A")]
public void ForbiddenCharacter_InAnySegment_IsDenied(string encoded)
public void GetRelativePath_ForbiddenCharacter_IsDenied(string encoded)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "images/a" + encoded + "b.png")));
@@ -174,9 +199,6 @@ public sealed class DocumentResourcePolicyBoundaryTests
[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();
@@ -6,14 +6,16 @@
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>
/// 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.
/// 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
{
@@ -26,14 +28,14 @@ public sealed class SanitizerAllowlistTests
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",
"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 =
@@ -46,18 +48,34 @@ public sealed class SanitizerAllowlistTests
"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",
"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()
@@ -66,13 +84,12 @@ public sealed class SanitizerAllowlistTests
<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>
/// <summary>No other element survives between allowed elements; its text may remain but the element does not.</summary>
[Fact]
public void Render_EveryOtherElement_IsRemoved()
{
@@ -87,11 +104,24 @@ public sealed class SanitizerAllowlistTests
/// <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\""));
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);
@@ -100,6 +130,21 @@ public sealed class SanitizerAllowlistTests
/// <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>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)]
@@ -107,8 +152,10 @@ public sealed class SanitizerAllowlistTests
[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)]
@@ -119,13 +166,12 @@ public sealed class SanitizerAllowlistTests
[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)]
[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>"));
@@ -51,7 +51,7 @@ public sealed class UntrustedInputValidationTests
/// <summary>The private source marker is new for every render, so an authored attribute cannot predict it.</summary>
[Fact]
public void SourceMap_EveryRender_UsesAnUnpredictableAttributeName()
public void AttributeName_EachSourceMap_IsUnpredictable()
{
string first = new DocumentSourceMap("# Title").AttributeName;
string second = new DocumentSourceMap("# Title").AttributeName;
@@ -67,7 +67,7 @@ public sealed class UntrustedInputValidationTests
[InlineData(1)]
[InlineData(5)]
[InlineData(6)]
public void Outline_RawHtmlHeadingLevel_MapsItsSource(int level)
public void SourceHeadings_RawHtmlHeadingLevel_MapsItsSource(int level)
{
string heading = $"<h{level}>Raw</h{level}>";
string source = "Intro\n\n" + heading + "\n\nAfter\n";
@@ -81,7 +81,7 @@ public sealed class UntrustedInputValidationTests
/// <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()
public void SourceHeadings_RawHtmlLevelSeven_IsNotAHeading()
{
var outline = new DocumentOutline("<h7>Not a heading</h7>\n");
Assert.Empty(outline.Headings);
@@ -178,7 +178,7 @@ public sealed class UntrustedInputValidationTests
/// <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()
public void IsExecutablePath_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('e', 32767 - 7) + ".exe";
string pastLimit = @"C:\" + new string('e', 32768 - 7) + ".exe";
@@ -195,7 +195,7 @@ public sealed class UntrustedInputValidationTests
/// <summary>Recent paths are kept up to the Windows limit and dropped one character past it.</summary>
[Fact]
public void RecentDocuments_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
public void Sanitize_RecentPathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('d', 32767 - 6) + ".md";
Assert.Equal([atLimit], RecentDocuments.Sanitize([atLimit, atLimit + "x"]));
@@ -205,7 +205,7 @@ public sealed class UntrustedInputValidationTests
/// <summary>A find term is accepted at its limit and refused one character past it.</summary>
[Fact]
public void DocumentFind_QueryAtAndPastTheLimit_IsBoundedExactly()
public void Constructor_FindTermAtAndPastTheLimit_IsBoundedExactly()
{
string atLimit = new('q', DocumentFind.MaximumQueryCharacters);
Assert.Equal(atLimit, new DocumentFind(atLimit + " tail", atLimit).Query);
@@ -216,7 +216,7 @@ public sealed class UntrustedInputValidationTests
/// <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()
public void FromBrowserResult_ViewportAtAndPastTheSizeCap_IsBoundedExactly()
{
string atCap = "[0.25," + new string(' ', 128 - 11) + "0.75]";
Assert.Equal(128, atCap.Length);
@@ -230,7 +230,7 @@ public sealed class UntrustedInputValidationTests
[Theory]
[InlineData(0)]
[InlineData(3)]
public void DocumentScrollState_ResultAtAndPastTheSizeCap_IsBoundedExactly(int headings)
public void FromBrowserResult_ScrollStateAtAndPastTheSizeCap_IsBoundedExactly(int headings)
{
int cap = headings * 128 + 512;
string atCap = "[1,2,[],[]" + new string(' ', cap - 11) + "]";
@@ -246,7 +246,7 @@ public sealed class UntrustedInputValidationTests
[InlineData("[1,2,[]]")]
[InlineData("[1,2,[],[],[]]")]
[InlineData("[1,2,[],[],0]")]
public void DocumentScrollState_WrongNumberOfParts_IsRejected(string json)
public void FromBrowserResult_ScrollStateWithWrongNumberOfParts_IsRejected(string json)
=> Assert.Null(DocumentScrollState.FromBrowserResult(json, 2));
@@ -257,7 +257,7 @@ public sealed class UntrustedInputValidationTests
[InlineData(double.PositiveInfinity, "padding:0px")]
[InlineData(5000, "padding:96px")]
[InlineData(-3, "padding:0px")]
public void PrintStyles_CorruptPadding_UsesTheRepairedValue(double padding, string expected)
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);
@@ -269,6 +269,28 @@ public sealed class UntrustedInputValidationTests
/// <summary>
/// Text the JSON reader cannot decode, an unpaired surrogate escape, is an invalid theme like any
/// other: parsing, import and saved-theme repair all handle it as one.
/// </summary>
[Theory]
[InlineData("\"name\": \"Nord\"", "\"name\": \"\\uD83D\"")]
[InlineData("\"background\": \"#2e3440\"", "\"background\": \"\\uD800\"")]
[InlineData("\"body\": \"System\"", "\"body\": \"A\\uDC00B\"")]
public void Parse_UndecodableText_IsRejectedAsAnInvalidTheme(string valid, string undecodable)
{
string original = ThemeCatalog.Find("Nord").Json;
Assert.Contains(valid, original, StringComparison.Ordinal);
string json = original.Replace(valid, undecodable, 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")]
@@ -280,7 +302,7 @@ public sealed class UntrustedInputValidationTests
/// <summary>A parse error anywhere in a theme is reported as invalid JSON content, the one failure callers handle.</summary>
/// <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\"")]
@@ -292,7 +314,7 @@ public sealed class UntrustedInputValidationTests
[InlineData("name", "\"Theme\\u007fName\"")]
[InlineData("fonts.heading", "\"Font\\u0001Name\"")]
[InlineData("fonts.code", "\"Font\\u009bName\"")]
public void ThemeParse_MalformedColorOrLabel_IsRejected(string path, string json)
public void Parse_MalformedColorOrLabel_IsRejected(string path, string json)
{
JsonNode root = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
string[] parts = path.Split('.');
@@ -21,35 +21,24 @@ public sealed class WindowsFileNamesTests
/// <summary>Every device name, in each letter case and with the suffixes Windows ignores.</summary>
public static TheoryData<string> DeviceSpellings()
/// <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()
{
var data = new TheoryData<string>();
foreach(string device in s_devices)
Assert.All(Spellings(), spelling =>
{
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;
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>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>
/// <summary>Names that only resemble a device stay ordinary file names; only spaces are ignored before the extension.</summary>
[Theory]
[InlineData("")]
[InlineData("console.md")]
@@ -58,10 +47,15 @@ public sealed class WindowsFileNamesTests
[InlineData("LPT.txt")]
[InlineData("xCON.png")]
[InlineData("CON-notes.md")]
[InlineData("CON-.md")]
[InlineData("null.md")]
[InlineData(" NUL.png")]
[InlineData("NUL .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));
@@ -72,23 +66,29 @@ public sealed class WindowsFileNamesTests
/// <summary>The characters Windows forbids in a name are exactly these nine.</summary>
/// <summary>The printable characters Windows forbids in a name; control characters are refused separately by each caller.</summary>
[Fact]
public void InvalidCharacters_ListsTheNineForbiddenCharacters() => Assert.Equal("\"*/:<>?\\|", new string(WindowsFileNames.InvalidCharacters.Order().ToArray()));
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, at any depth.</summary>
[Theory]
[MemberData(nameof(DeviceSpellings))]
public void ResourcePolicy_DeviceSegment_DeniesImageAndDocument(string name)
/// <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();
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")));
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")));
}
@@ -104,4 +104,22 @@ public sealed class WindowsFileNamesTests
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() });
}