Compare commits

..
Author SHA1 Message Date
dependabot[bot] 9a5d5d7859 ci: Bump the actions group across 1 directory with 8 updates
Bumps the actions group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [KofTwentyTwo/standards/.github/workflows/dotnet.yml](https://github.com/koftwentytwo/standards) | `0.1.2` | `1.2.1` |
| [KofTwentyTwo/standards/.github/workflows/codeql.yml](https://github.com/koftwentytwo/standards) | `0.1.2` | `1.2.1` |
| [KofTwentyTwo/standards/.github/workflows/pr.yml](https://github.com/koftwentytwo/standards) | `0.1.2` | `1.2.1` |
| [actions/github-script](https://github.com/actions/github-script) | `8.0.0` | `9.0.0` |
| [ossf/scorecard-action](https://github.com/ossf/scorecard-action) | `2.4.4` | `2.5.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.3` | `4.39.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6.5.0` | `7.1.0` |
| [KofTwentyTwo/standards/.github/workflows/security.yml](https://github.com/koftwentytwo/standards) | `0.1.2` | `1.2.1` |



Updates `KofTwentyTwo/standards/.github/workflows/dotnet.yml` from 0.1.2 to 1.2.1
- [Release notes](https://github.com/koftwentytwo/standards/releases)
- [Changelog](https://github.com/KofTwentyTwo/standards/blob/main/CHANGELOG.md)
- [Commits](https://github.com/koftwentytwo/standards/compare/fdf5ed029603c938615a32b1beca3628b8787945...70b555bd5658273090769991bfc4c9b6681dde18)

Updates `KofTwentyTwo/standards/.github/workflows/codeql.yml` from 0.1.2 to 1.2.1
- [Release notes](https://github.com/koftwentytwo/standards/releases)
- [Changelog](https://github.com/KofTwentyTwo/standards/blob/main/CHANGELOG.md)
- [Commits](https://github.com/koftwentytwo/standards/compare/fdf5ed029603c938615a32b1beca3628b8787945...70b555bd5658273090769991bfc4c9b6681dde18)

Updates `KofTwentyTwo/standards/.github/workflows/pr.yml` from 0.1.2 to 1.2.1
- [Release notes](https://github.com/koftwentytwo/standards/releases)
- [Changelog](https://github.com/KofTwentyTwo/standards/blob/main/CHANGELOG.md)
- [Commits](https://github.com/koftwentytwo/standards/compare/fdf5ed029603c938615a32b1beca3628b8787945...70b555bd5658273090769991bfc4c9b6681dde18)

Updates `actions/github-script` from 8.0.0 to 9.0.0
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/ed597411d8f924073f98dfc5c65a23a2325f34cd...3a2844b7e9c422d3c10d287c895573f7108da1b3)

Updates `ossf/scorecard-action` from 2.4.4 to 2.5.0
- [Release notes](https://github.com/ossf/scorecard-action/releases)
- [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md)
- [Commits](https://github.com/ossf/scorecard-action/compare/2d1146689b8cda280b9bc96326124645441f03bc...fa7117db83c52d787ae5a89f350999701505d78e)

Updates `github/codeql-action/upload-sarif` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `actions/setup-node` from 6.5.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/249970729cb0ef3589644e2896645e5dc5ba9c38...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `KofTwentyTwo/standards/.github/workflows/security.yml` from 0.1.2 to 1.2.1
- [Release notes](https://github.com/koftwentytwo/standards/releases)
- [Changelog](https://github.com/KofTwentyTwo/standards/blob/main/CHANGELOG.md)
- [Commits](https://github.com/koftwentytwo/standards/compare/fdf5ed029603c938615a32b1beca3628b8787945...70b555bd5658273090769991bfc4c9b6681dde18)

---
updated-dependencies:
- dependency-name: KofTwentyTwo/standards/.github/workflows/dotnet.yml
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: KofTwentyTwo/standards/.github/workflows/codeql.yml
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: KofTwentyTwo/standards/.github/workflows/pr.yml
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: ossf/scorecard-action
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: KofTwentyTwo/standards/.github/workflows/security.yml
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 12:18:55 +00:00
18 changed files with 28 additions and 579 deletions

No files matched your search

-50
View File
@@ -1,50 +0,0 @@
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
windows:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
secrets: inherit
with:
commands: |
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror --no-restore
# The WinUI app project is built directly so the format workspace can load its generated XAML code.
dotnet build src/MarkdownViewr.Windows/MarkdownViewr.Windows.csproj -p:Platform=x64 -warnaserror --no-restore
$env:Platform = 'x64'
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
Remove-Item Env:Platform
dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --logger trx --results-directory TestResults
pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core
# PowerShell build scripts: analyzer and Pester boundary tests with pinned modules saved to the job's temp directory.
$modules = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "psmodules-$env:GITHUB_RUN_ID"
New-Item -ItemType Directory -Force -Path $modules | Out-Null
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
Import-Module (Join-Path $modules PSScriptAnalyzer/1.25.0/PSScriptAnalyzer.psd1)
Import-Module (Join-Path $modules Pester/5.7.1/Pester.psd1)
foreach ($file in Get-ChildItem -LiteralPath build -Recurse -File | Where-Object Extension -In '.ps1', '.psm1', '.psd1') {
# Casing runs in its own pass: command metadata races other rules in 1.25.0 (PSScriptAnalyzer#1708).
$findings = @(
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -IncludeRule PSUseCorrectCasing
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -ExcludeRule PSUseCorrectCasing
)
if ($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $($file.Name)" }
}
# The Pester suites assert on child processes' exit codes themselves.
$PSNativeCommandUseErrorActionPreference = $false
$pester = New-PesterConfiguration
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/GitHubRepository.Tests.ps1', 'build/Distribution.Tests.ps1', 'build/InstalledUpdates.Tests.ps1', 'build/PackageBytes.Tests.ps1', 'build/ReleaseSigning.Tests.ps1')
$pester.Run.PassThru = $true
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
scripts:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
with:
node-version: '24'
install: npm ci --ignore-scripts
commands: |
npm run lint
npm test
npm audit signatures
+1 -1
View File
@@ -97,7 +97,7 @@ jobs:
artifacts/installed-smoke-*/*.pdf
retention-days: 7
ci:
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1
permissions:
contents: read # check out, build, and test; nothing is written
with:
+1 -1
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
codeql:
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1
permissions:
contents: read # check out the code
security-events: write # upload CodeQL results to code scanning
+1 -1
View File
@@ -14,7 +14,7 @@ concurrency:
jobs:
pr:
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1
permissions:
contents: read # read the commits being merged
pull-requests: read # read the pull request title and dependency diff
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
run: ./build/Assert-ReleaseSigning.ps1
- name: Require passing CI for the exact merged release tree
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { run } = require('./.github/scripts/assert-release-source.cjs');
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Verify the current commit review record
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { run } = require('./.github/scripts/review-gate.cjs');
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
with:
results_file: scorecard.sarif
results_format: sarif
@@ -48,7 +48,7 @@ jobs:
retention-days: 30
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: scorecard.sarif
category: scorecard
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
- name: Install locked tools without lifecycle scripts
+1 -1
View File
@@ -21,7 +21,7 @@ concurrency:
jobs:
security:
uses: KofTwentyTwo/standards/.github/workflows/security.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
uses: KofTwentyTwo/standards/.github/workflows/security.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1
permissions:
contents: read # check out the code and its history
security-events: write # upload Trivy and zizmor SARIF to code scanning
+1 -1
View File
@@ -46,7 +46,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
| 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. Element nesting is bounded at 256 levels before any library walks the parsed tree recursively, with an early count that stops runaway nesting while it is still being parsed; deeper markup is refused as an unsupported document. Parse time is not bounded: it grows with document size, runs off the window thread, and can be cancelled between the render, the parse and each heading |
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
| Settings corruption / UI exception | AppKit shell sanitation and crash handling; inherited foundation tests |
| Tampered installer/update | Fail-closed signing/provenance/checksum release pipeline; real Azure CI authorization and signed artifacts remain unverified |
| Privileged repository review abuse | Trusted review workflow only; current-SHA human acknowledgement remains required |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.403",
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
@@ -18,25 +18,16 @@ public sealed class DocumentOutline
/// <summary>
/// Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.
/// A large document takes time that grows with its size: cancellation is honoured between the
/// render, the parse and each heading, so a closed window or a newer load does not wait for it.
/// </summary>
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide, MarkdownExtensions? extensions = null, CancellationToken cancellation = default)
/// <summary>Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.</summary>
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide, MarkdownExtensions? extensions = null)
{
var sources = new DocumentSourceMap(markdown);
var sourceHeadings = new List<DocumentSourceHeading>();
cancellation.ThrowIfCancellationRequested();
string rendered = new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources);
cancellation.ThrowIfCancellationRequested();
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(rendered);
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources));
IElement body = document.Body!;
var fragments = new Dictionary<string, string>(StringComparer.Ordinal);
var nextSuffix = new Dictionary<string, int>(StringComparer.Ordinal);
foreach(IElement element in body.QuerySelectorAll("h1,h2,h3,h4,h5,h6"))
{
cancellation.ThrowIfCancellationRequested();
var heading = new DocumentHeading(_headings.Count, element.LocalName[1] - '0', element.TextContent.Trim());
_headings.Add(heading);
if(element.GetAttribute(sources.AttributeName) is { } identity && sources.TryGetPosition(identity, out (int Start, int End) position))
@@ -46,15 +37,11 @@ public sealed class DocumentOutline
_elements.Add(element, heading);
element.Id = "mv-heading-" + heading.Index.ToString(CultureInfo.InvariantCulture);
string slug = Markdig.Helpers.LinkHelper.UrilizeAsGfm(heading.Title);
// Repeated titles continue from the last suffix used for that title instead of rescanning from one.
string fragment = slug;
int suffix = nextSuffix.GetValueOrDefault(slug, 1);
while(fragments.ContainsKey(fragment))
for(int suffix = 1; fragments.ContainsKey(fragment); suffix++)
{
fragment = slug + "-" + suffix.ToString(CultureInfo.InvariantCulture);
suffix++;
}
nextSuffix[slug] = suffix;
fragments.Add(fragment, element.Id);
}
foreach(IElement element in body.QuerySelectorAll("[" + sources.AttributeName + "]"))
@@ -69,7 +56,6 @@ public sealed class DocumentOutline
link.SetAttribute("href", "#" + target);
}
}
cancellation.ThrowIfCancellationRequested();
foreach(IElement element in body.QuerySelectorAll("*").Reverse())
{
if(!_elements.ContainsKey(element))
@@ -142,12 +142,10 @@ internal sealed class DocumentSourceMap
string html = string.Join('\n', lines.Take(block.Lines.Count).Select(line => line.Slice.ToString()));
int? close = null;
bool seenTag = false;
var nesting = new HtmlNesting();
var parser = new HtmlParser(new HtmlParserOptions
{
OnToken = (token, range) =>
{
nesting.Observe(token);
if(!seenTag && token.Type is HtmlTokenType.StartTag or HtmlTokenType.EndTag
&& token.Name.Length == 2 && token.Name[0] == 'h' && token.Name[1] is >= '1' and <= '6')
{
@@ -211,13 +209,11 @@ internal sealed class DocumentSourceMap
private static HtmlParser CreateSourceParser(Dictionary<int, int> headingEnds)
{
int pendingHeading = -1;
var nesting = new HtmlNesting();
return new HtmlParser(new HtmlParserOptions
{
IsKeepingSourceReferences = true,
OnToken = (token, range) =>
{
nesting.Observe(token);
if(token.Name.Length == 2 && token.Name[0] == 'h' && token.Name[1] is >= '1' and <= '6')
{
if(token.Type == HtmlTokenType.StartTag)
@@ -1,90 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using AngleSharp.Html.Parser.Tokens;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>
/// Bounds how deeply authored HTML may nest. Libraries that walk the parsed tree recursively
/// overflow the stack on a few thousand levels, and a stack overflow cannot be caught, so the
/// bound is enforced before any such walk: a cheap count while tokens arrive stops runaway
/// input early, and an exact measurement of the parsed tree applies the real limit.
/// </summary>
internal sealed class HtmlNesting
{
/// <summary>The deepest element nesting a document may have; ordinary documents use a few dozen levels.</summary>
public const int MaximumDepth = 256;
/// <summary>Start tags left open before parsing stops; an upper bound on depth that keeps the parser's work small.</summary>
public const int MaximumOpenTags = 4096;
private const string Message = "The document nests HTML more deeply than the supported limit.";
private static readonly HashSet<string> s_neverOpen = new(StringComparer.Ordinal)
{
// Void elements have no content.
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr",
// A new one of these closes the previous one, so siblings left unclosed do not nest.
"caption", "colgroup", "dd", "dt", "li", "optgroup", "option", "p", "tbody", "td", "tfoot", "th", "thead", "tr",
};
private readonly Stack<string> _open = new();
/// <summary>
/// Counts start tags that are still open. An end tag closes only the tag on top, so the count
/// can exceed the real depth for carelessly closed markup but never falls below it.
/// </summary>
public void Observe(HtmlToken token)
{
if(token.Type == HtmlTokenType.StartTag && !s_neverOpen.Contains(token.Name))
{
_open.Push(token.Name);
if(_open.Count > MaximumOpenTags)
{
throw new ArgumentException(Message, nameof(token));
}
}
else if(token.Type == HtmlTokenType.EndTag && _open.TryPeek(out string? top) && string.Equals(top, token.Name, StringComparison.Ordinal))
{
_open.Pop();
}
}
/// <summary>Measures the parsed tree without recursion and rejects one deeper than <see cref="MaximumDepth"/>.</summary>
public static void ThrowIfTooDeep(INode root)
{
int depth = 0;
INode? node = root;
while(node is not null)
{
if(node.FirstChild is { } child)
{
node = child;
if(++depth > MaximumDepth)
{
throw new ArgumentException(Message, nameof(root));
}
}
else
{
// A leaf: climb to the nearest ancestor that has a sibling still to visit.
while(node != root && node!.NextSibling is null)
{
node = node.Parent;
depth--;
}
node = node == root ? null : node!.NextSibling;
}
}
}
}
@@ -6,8 +6,6 @@
using System.Text;
using System.Text.RegularExpressions;
using AngleSharp;
using AngleSharp.Css.Parser;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
@@ -26,14 +24,6 @@ public sealed class MarkdownRenderer
/// <summary>Bounds parser work before an opened document can consume unbounded memory.</summary>
public const int MaximumCharacters = 16 * 1024 * 1024;
// The sanitizer's own parser settings: scripting on, so noscript content stays text, and its CSS-aware context.
private static readonly IBrowsingContext s_sanitizerContext = BrowsingContext.New(Configuration.Default.WithCss(new CssParserOptions
{
IsIncludingUnknownDeclarations = true,
IsIncludingUnknownRules = true,
IsToleratingInvalidSelectors = true,
}));
private readonly MarkdownPipeline _pipeline;
private readonly MarkdownExtensions _extensions;
@@ -110,23 +100,7 @@ public sealed class MarkdownRenderer
_ => "",
} : "";
string html = sources is null ? RenderDocument(Markdown.Parse(body, _pipeline)) : sources.Render(body, _pipeline, markdown.Length - body.Length, RenderDocument);
return Sanitize(prefix + html);
}
/// <summary>
/// Parses once with the sanitizer's own settings, proves the tree is shallow enough for the
/// sanitizer's recursive passes, and only then sanitizes and serializes it.
/// </summary>
private string Sanitize(string html)
{
var nesting = new HtmlNesting();
var parser = new HtmlParser(new HtmlParserOptions { IsScripting = true, OnToken = (token, _) => nesting.Observe(token) }, s_sanitizerContext);
using IHtmlDocument document = parser.ParseDocument("<!doctype html><html><body>" + html);
HtmlNesting.ThrowIfTooDeep(document.Body!);
_sanitizer.SanitizeDom(document, document.Body!);
return document.Body!.ChildNodes.ToHtml(_sanitizer.OutputFormatter);
return _sanitizer.Sanitize(prefix + html);
}
@@ -150,26 +124,21 @@ public sealed class MarkdownRenderer
var block = (HtmlBlock)node;
string html = string.Join('\n', block.Lines.Lines.Take(block.Lines.Count).Select(line => line.Slice.ToString()));
var changes = new List<(int Start, int End, string Value)>();
var nesting = new HtmlNesting();
var parser = new HtmlParser(new HtmlParserOptions
{
OnToken = (token, range) =>
{
nesting.Observe(token);
if(token.Type is HtmlTokenType.StartTag or HtmlTokenType.EndTag && token.Name is "code" or "pre")
{
codeDepth = Math.Max(0, codeDepth + (token.Type == HtmlTokenType.StartTag ? 1 : -1));
}
else if(token.Type == HtmlTokenType.Character && codeDepth == 0)
{
// An unterminated script or comment reports a range past the end of the block.
int start = Math.Min(range.Start.Index, html.Length);
int end = Math.Clamp(range.End.Index, start, html.Length);
string text = html[start..end];
string text = html[range.Start.Index..range.End.Index];
string formatted = FormatRawText(text, extendSubscript);
if(!string.Equals(text, formatted, StringComparison.Ordinal))
{
changes.Add((start, end, formatted));
changes.Add((range.Start.Index, range.End.Index, formatted));
}
}
},
@@ -179,7 +148,13 @@ public sealed class MarkdownRenderer
{
continue;
}
string[] lines = Rewrite(html, changes).Split('\n');
var rewritten = new StringBuilder(html);
for(int index = changes.Count - 1; index >= 0; index--)
{
(int start, int end, string value) = changes[index];
rewritten.Remove(start, end - start).Insert(start, value);
}
string[] lines = rewritten.ToString().Split('\n');
for(int index = 0; index < block.Lines.Count; index++)
{
block.Lines.Lines[index].Slice = new Markdig.Helpers.StringSlice(lines[index]);
@@ -190,28 +165,6 @@ public sealed class MarkdownRenderer
/// <summary>
/// Applies replacements in one forward pass, so the work is linear in the block. Malformed markup
/// can report a text run that starts inside an earlier one; that run is left as authored.
/// </summary>
private static string Rewrite(string html, List<(int Start, int End, string Value)> changes)
{
var rewritten = new StringBuilder(html.Length + 64);
int copied = 0;
foreach((int start, int end, string value) in changes)
{
if(start < copied)
{
continue;
}
rewritten.Append(html, copied, start - copied).Append(value);
copied = end;
}
return rewritten.Append(html, copied, html.Length - copied).ToString();
}
/// <summary>Reference extension syntax applies only to tokenized visible text, with bounded regex work and unchanged entity spelling.</summary>
private string FormatRawText(string text, bool extendSubscript)
{
@@ -245,12 +198,10 @@ public sealed class MarkdownRenderer
}
else if(node is HtmlBlock block)
{
var nesting = new HtmlNesting();
var parser = new HtmlParser(new HtmlParserOptions
{
OnToken = (token, _) =>
{
nesting.Observe(token);
if(token.Type is HtmlTokenType.StartTag or HtmlTokenType.EndTag && token.Name is "code" or "pre")
{
codeDepth = Math.Max(0, codeDepth + (token.Type == HtmlTokenType.StartTag ? 1 : -1));
@@ -32,9 +32,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.14, )",
"resolved": "10.0.14",
"contentHash": "YlTamXppEQls6DYWt7RdUuwWDb0lyw/vXCaGeQpLCpsLmfwGuCkOz/vIGgMTV7ECsJcavkCOkxZXv9s8rdAHkQ=="
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"AngleSharp": {
"type": "Transitive",
@@ -1,218 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Deeply nested markup must be refused as an ordinary error. The renders here run on a thread
/// with a quarter of the default stack, so a recursive walk that the bound failed to stop would
/// end the test run instead of passing by luck.
/// </summary>
public sealed class HtmlNestingTests
{
private const int SmallStackBytes = 256 * 1024;
/// <summary>The deepest markup the bound allows renders completely, with stack to spare.</summary>
[Fact]
public void Render_NestingAtTheLimit_RendersOnASmallStack()
{
int levels = HtmlNesting.MaximumDepth - 1;
string markup = Repeat("<div>", levels) + "deepest" + Repeat("</div>", levels);
string? html = null;
Assert.Null(OnSmallStack(() => html = new MarkdownRenderer().Render(markup)));
using IHtmlDocument parsed = new HtmlParser().ParseDocument(html!);
Assert.Equal(levels, parsed.QuerySelectorAll("div").Length);
Assert.Contains("deepest", html, StringComparison.Ordinal);
Assert.Equal(256, HtmlNesting.MaximumDepth);
}
/// <summary>One level past the limit is an argument error, the failure the reader already reports for an unsupported document.</summary>
[Fact]
public void Render_NestingOnePastTheLimit_IsRejected()
{
string markup = Repeat("<div>", HtmlNesting.MaximumDepth) + "too deep";
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup)));
}
/// <summary>
/// A few kilobytes of nested elements, in every shape that nests, are refused before any
/// library walks the tree recursively.
/// </summary>
[Theory]
[InlineData("<div>", 3000)]
[InlineData("<b>", 3000)]
[InlineData("<span><i>", 1500)]
[InlineData("<table><tr><td>", 3000)]
[InlineData("<blockquote>", 2000)]
[InlineData("<ul><li>", 2000)]
[InlineData("<details><summary>s</summary>", 1500)]
[InlineData("<div></section>", 3000)]
public void Render_DeeplyNestedMarkup_IsRejectedBeforeAnyRecursiveWalk(string unit, int count)
{
string markup = Repeat(unit, count) + "x";
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup)));
Assert.IsType<ArgumentException>(OnSmallStack(() => _ = new DocumentOutline(markup)));
}
/// <summary>
/// Nesting far past the limit is stopped by the count kept while tokens arrive, not after the
/// whole input has been parsed: parsing it to the end takes time that grows with its square.
/// </summary>
[Fact]
public void Render_RunawayNesting_IsStoppedWhileParsing()
{
string block = Repeat("<div>", 100000) + "x";
ArgumentException stoppedInBlock = Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(block)));
Assert.Equal("token", stoppedInBlock.ParamName);
string inline = "Paragraph " + Repeat("<b>", 100000) + "x";
ArgumentException stoppedInline = Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(inline)));
Assert.Equal("token", stoppedInline.ParamName);
ArgumentException measured = Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(Repeat("<div>", 300) + "x")));
Assert.Equal("root", measured.ParamName);
}
/// <summary>Nesting assembled from inline tags in a paragraph, or from many separate blocks, is measured as a whole.</summary>
[Theory]
[InlineData("Paragraph ", "<b>", "")]
[InlineData("", "<div>", "\n\n")]
[InlineData("- item ", "<span>", "")]
[InlineData("> quoted ", "<i>", "")]
public void Render_NestingAcrossInlineTagsAndBlocks_IsRejected(string prefix, string unit, string separator)
{
string markup = prefix + string.Concat(Enumerable.Repeat(unit + separator, 400)) + "x";
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup)));
}
/// <summary>Nesting hidden in frontmatter rendered as Markdown is bounded like the body.</summary>
[Fact]
public void Render_DeepNestingInMarkdownFrontmatter_IsRejected()
{
string markup = "---\n" + Repeat("<div>", 3000) + "\n---\nBody";
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup, FrontmatterMode.Markdown)));
Assert.Null(OnSmallStack(() => new MarkdownRenderer().Render(markup, FrontmatterMode.Hide)));
}
/// <summary>Wide documents are not deep: thousands of siblings, unclosed paragraphs, list items and cells render normally.</summary>
[Theory]
[InlineData("<div>x</div>")]
[InlineData("<p>x")]
[InlineData("x<br>")]
[InlineData("<span>x</span><b>y</b>")]
public void Render_ManySiblings_AreNotMistakenForNesting(string unit)
{
string markup = "<div>" + Repeat(unit, HtmlNesting.MaximumOpenTags + 500) + "</div>";
string? html = null;
Assert.Null(OnSmallStack(() => html = new MarkdownRenderer().Render(markup)));
Assert.Contains("x", html, StringComparison.Ordinal);
}
/// <summary>List items and table cells left unclosed are siblings, however many there are.</summary>
[Fact]
public void Render_ManyUnclosedItemsAndCells_AreNotMistakenForNesting()
{
string markup = "<ul>" + Repeat("<li>item", 5000) + "</ul>\n\n<table>" + Repeat("<tr><td>a<td>b", 5000) + "</table>";
string? html = null;
Assert.Null(OnSmallStack(() => html = new MarkdownRenderer().Render(markup)));
using IHtmlDocument parsed = new HtmlParser().ParseDocument(html!);
Assert.Equal(5000, parsed.QuerySelectorAll("li").Length);
Assert.Equal(10000, parsed.QuerySelectorAll("td").Length);
}
/// <summary>The early count allows exactly its bound of open tags and stops at the next one.</summary>
[Theory]
[InlineData(HtmlNesting.MaximumOpenTags, false)]
[InlineData(HtmlNesting.MaximumOpenTags + 1, true)]
public void Observe_OpenTagsAtAndPastTheBound_StopsExactlyPastIt(int count, bool stopped)
{
Assert.Equal(stopped, Record.Exception(() => Tokenize(Repeat("<div>", count))) is ArgumentException);
Assert.Equal(4096, HtmlNesting.MaximumOpenTags);
}
/// <summary>
/// The early count never falls below the real depth: an end tag closes only the tag on top,
/// so an end tag the parser would ignore cannot be used to hide nesting from the count.
/// </summary>
[Theory]
[InlineData("<div></span>")]
[InlineData("<div><table><tr><td></div>")]
[InlineData("<section><p></article>")]
public void Observe_EndTagThatDoesNotCloseTheOpenTag_DoesNotReduceTheCount(string unit)
=> Assert.IsType<ArgumentException>(Record.Exception(() => Tokenize(Repeat(unit, HtmlNesting.MaximumOpenTags + 1))));
/// <summary>Properly closed elements, void elements and self-closing siblings never accumulate.</summary>
[Theory]
[InlineData("<div><span>x</span></div>")]
[InlineData("<img src=x><br><hr><input>")]
[InlineData("<p>x<li>y<td>z<option>o")]
public void Observe_ClosedVoidAndImplicitlyClosedTags_DoNotAccumulate(string unit)
=> Assert.Null(Record.Exception(() => Tokenize(Repeat(unit, HtmlNesting.MaximumOpenTags + 1))));
/// <summary>The exact measurement walks every branch and counts from the root it is given.</summary>
[Fact]
public void ThrowIfTooDeep_ShallowWideAndBranchingTrees_AreAccepted()
{
using IHtmlDocument empty = new HtmlParser().ParseDocument("");
HtmlNesting.ThrowIfTooDeep(empty.Body!);
using IHtmlDocument branching = new HtmlParser().ParseDocument(Repeat("<div>", 200) + "a" + Repeat("</div>", 200) + "<p>b</p>" + Repeat("<span>c</span>", 2000) + Repeat("<div>", 254) + "d");
HtmlNesting.ThrowIfTooDeep(branching.Body!);
using IHtmlDocument late = new HtmlParser().ParseDocument("<p>first</p><p>second</p>" + Repeat("<div>", 256) + "deep");
Assert.Throws<ArgumentException>(() => HtmlNesting.ThrowIfTooDeep(late.Body!));
}
/// <summary>Feeds markup through a parser whose tokens go to a fresh count, as the renderer does.</summary>
private static void Tokenize(string html)
{
var nesting = new HtmlNesting();
using IHtmlDocument document = new HtmlParser(new HtmlParserOptions { OnToken = (token, _) => nesting.Observe(token) }).ParseDocument(html);
}
/// <summary>Runs on a thread with a quarter of the default stack and returns what it threw, if anything.</summary>
private static Exception? OnSmallStack(Action action)
{
Exception? failure = null;
var thread = new Thread(() => failure = Record.Exception(action), SmallStackBytes);
thread.Start();
thread.Join();
return failure;
}
/// <summary>The unit repeated without separators.</summary>
private static string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count));
}
@@ -1,126 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Malformed and very repetitive documents render, with work that grows in step with their size.</summary>
public sealed class RendererRobustnessTests
{
/// <summary>An unterminated script followed by a comment opener reports a text run past the end of its block; the document still opens and stays passive.</summary>
[Theory]
[InlineData("<script>x<!--")]
[InlineData("<script><!--")]
[InlineData("<script>==a==\n<!--")]
[InlineData("<script>*<!--")]
[InlineData("<div>==kept==</div><script>\n<!--\n")]
public void Render_UnterminatedScriptAndComment_OpensAsPassiveMarkup(string markdown)
{
string html = new MarkdownRenderer().Render(markdown + "\n\nAfter the block.");
Assert.DoesNotContain("<script", html, StringComparison.OrdinalIgnoreCase);
var outline = new DocumentOutline("# Title\n\n" + markdown);
Assert.Equal("Title", Assert.Single(outline.Headings).Title);
Assert.DoesNotContain("<script", outline.Html, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Markup is parsed with scripting on, as the sanitizer library parses it: the content of a
/// noscript element is text that ends at the first closing tag, wherever that tag appears.
/// </summary>
[Fact]
public void Render_NoscriptContent_IsParsedAsTextEndingAtItsFirstClosingTag()
{
const string Markdown = "<noscript><p title=\"</noscript><img src=x onerror=execute() alt=after>\"></noscript>";
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(Markdown));
AngleSharp.Dom.IElement image = Assert.Single(parsed.QuerySelectorAll("img"));
Assert.Equal(["alt", "src"], image.Attributes.Select(attribute => attribute.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Empty(parsed.QuerySelectorAll("noscript,p[title]"));
}
/// <summary>Markup broken so that one text run starts inside another is left as authored there, formatted elsewhere, and still sanitized.</summary>
[Fact]
public void Render_OverlappingTextRuns_RendersWithoutRewritingTheOverlap()
{
const string Markdown = "<iframe srcdoc='<script>execute()</scr<pre><code>==literal== ^literal^ ++literal++</code></pre>\nipt>'></iframe></style><script>execute()</script>";
string html = new MarkdownRenderer().Render(Markdown);
using IHtmlDocument parsed = new HtmlParser().ParseDocument(html);
Assert.Empty(parsed.QuerySelectorAll("script,iframe,style"));
Assert.DoesNotContain("execute()</script>", html, StringComparison.Ordinal);
}
/// <summary>Every text run in a long raw block is formatted once, in place, in document order.</summary>
[Fact]
public void Render_ManyFormattedRunsInOneBlock_FormatsEachInOrder()
{
const int Runs = 3000;
string block = "<div>" + string.Concat(Enumerable.Range(0, Runs).Select(index => $"<span>==m{index}== and ^s{index}^</span>")) + "</div>";
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(block));
Assert.Equal(Enumerable.Range(0, Runs).Select(index => "m" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)), parsed.QuerySelectorAll("mark").Select(mark => mark.TextContent), StringComparer.Ordinal);
Assert.Equal(Enumerable.Range(0, Runs).Select(index => "s" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)), parsed.QuerySelectorAll("sup").Select(sup => sup.TextContent), StringComparer.Ordinal);
Assert.Equal(Runs, parsed.QuerySelectorAll("span").Length);
}
/// <summary>A raw block whose text needs no formatting is passed through unchanged between its neighbours.</summary>
[Fact]
public void Render_FormattedRunsBetweenUnchangedText_KeepsTheTextAroundThem()
{
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new MarkdownRenderer().Render("<div>before <b>==first==</b> middle <i>plain</i> ++last++ after</div>"));
Assert.Equal("before first middle plain last after", parsed.Body!.TextContent.Trim());
Assert.Equal("first", Assert.Single(parsed.QuerySelectorAll("mark")).TextContent);
Assert.Equal("last", Assert.Single(parsed.QuerySelectorAll("ins")).TextContent);
}
/// <summary>Thousands of headings with one title get distinct anchors, and a link to any of them finds its heading.</summary>
[Fact]
public void Outline_ManyHeadingsWithTheSameTitle_AnchorEachOne()
{
const int Count = 4000;
string markdown = string.Concat(Enumerable.Repeat("# Same\n\n", Count)) + "[first](#same) [second](#same-1) [last](#same-3999) [none](#same-4000)\n";
var outline = new DocumentOutline(markdown);
Assert.Equal(Count, outline.Headings.Count);
using IHtmlDocument parsed = new HtmlParser().ParseDocument(outline.Html);
Assert.Equal(["#mv-heading-0", "#mv-heading-1", "#mv-heading-3999", "#same-4000"], parsed.QuerySelectorAll("a").Select(link => link.GetAttribute("href")!), StringComparer.Ordinal);
}
/// <summary>A heading that is literally named like a generated anchor keeps its own anchor; repeats step past it.</summary>
[Theory]
[InlineData("# Same\n\n# Same-1\n\n# Same\n\n# Same\n\n", new[] { "#same", "#same-1", "#same-2", "#same-3" })]
[InlineData("# Same-1\n\n# Same\n\n# Same\n\n# Same-1\n\n", new[] { "#same-1", "#same", "#same-2", "#same-1-1" })]
[InlineData("# A\n\n# B\n\n# A\n\n# B\n\n", new[] { "#a", "#b", "#a-1", "#b-1" })]
public void Outline_RepeatedAndLiteralSuffixTitles_AnchorInDocumentOrder(string headings, string[] anchors)
{
string markdown = headings + string.Concat(anchors.Select(anchor => $"[link]({anchor}) "));
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new DocumentOutline(markdown).Html);
Assert.Equal(["#mv-heading-0", "#mv-heading-1", "#mv-heading-2", "#mv-heading-3"], parsed.QuerySelectorAll("a").Select(link => link.GetAttribute("href")!), StringComparer.Ordinal);
}
/// <summary>A load that is no longer wanted stops instead of building its outline.</summary>
[Fact]
public void Outline_CancelledBeforeItStarts_StopsWithoutBuilding()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(() => new DocumentOutline("# Title\n\nBody", cancellation: cancellation.Token));
Assert.Equal("Title", Assert.Single(new DocumentOutline("# Title\n\nBody", cancellation: CancellationToken.None).Headings).Title);
}
}