Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 b095d1a183 fix(rendering): refuse unsupported markup as an error and keep large documents linear
node / node (push) Successful in 14s
build / scripts (push) Successful in 14s
windows / windows (push) Successful in 1m57s
build / windows (push) Successful in 1m57s
Element nesting is bounded before any library walks the parsed tree: a
count kept while tokens arrive stops runaway input early, and an exact
measurement of the parsed tree applies the limit of 256 levels. The
renderer parses once with the sanitizer's own settings and sanitizes that
tree, so the bound is enforced between the parse and the sanitizer's
passes. Deeper markup is an ArgumentException, which the reader already
reports as an unsupported document.

An unterminated script or comment can report a text run past the end of
its block, and malformed markup can report one that starts inside
another; both are handled instead of failing the open. Raw-text
formatting is applied in one forward pass, and repeated heading titles
continue from the last anchor suffix used, so neither grows with the
square of the document. DocumentOutline accepts a cancellation token that
is honoured between its phases.

The threat model states that nesting is bounded and parse time is not.

Closes #50

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 17:19:26 -06:00
James MaesandClaude Opus 5.5 8763ff3206 ci: first run on ci-workflows v1 (native workflows)
node / node (push) Successful in 19s
build / scripts (push) Successful in 19s
windows / windows (push) Successful in 1m42s
build / windows (push) Successful in 1m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:28 -06:00
james.maes 2038be4355 Merge pull request 'ci: build on the shared ci-workflows Windows workflow' (#45) from ci/shared-windows into main 2026-11-08 13:49:08 +00:00
James MaesandClaude Opus 5.5 aff40e8857 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:48:42 -06:00
James MaesandClaude Opus 5.5 6e69f9619d ci: call the shared workflow from ci-workflows main
node / node (push) Successful in 18s
build / scripts (push) Successful in 18s
windows / windows (push) Successful in 1m39s
build / windows (push) Successful in 1m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:45:49 -06:00
James MaesandClaude Opus 5.5 1105a4d076 ci: let the Pester suites see child exit codes
node / node (push) Successful in 17s
build / scripts (push) Successful in 17s
windows / windows (push) Successful in 1m31s
build / windows (push) Successful in 1m31s
They assert on failing child processes themselves; the shared workflow's
native-exit-code preference turned those expected failures into errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:42:14 -06:00
James MaesandClaude Opus 5.5 fb83501566 ci: create the module directory before saving the pinned modules
node / node (push) Successful in 20s
build / scripts (push) Successful in 20s
windows / windows (push) Failing after 1m40s
build / windows (push) Failing after 1m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:38:23 -06:00
James MaesandClaude Opus 5.5 42220c0e06 ci: run the coverage gate as a separate pwsh process
node / node (push) Successful in 26s
build / scripts (push) Successful in 26s
windows / windows (push) Failing after 1m13s
build / windows (push) Failing after 1m13s
Assert-Coverage.ps1 reports a shortfall with exit 1, which only stops the
build when the script runs as a native process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:35:29 -06:00
James MaesandClaude Opus 5.5 135256c9f1 build: keep the final newline in the refreshed lock file
node / node (push) Successful in 19s
build / scripts (push) Successful in 18s
windows / windows (push) Failing after 1m13s
build / windows (push) Failing after 1m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:32:50 -06:00
James MaesandClaude Opus 5.5 4c7406723c build: pin SDK 10.0.403 and refresh the lock files
node / node (push) Successful in 22s
build / scripts (push) Successful in 22s
windows / windows (push) Canceled after 1m16s
build / windows (push) Canceled after 1m16s
The KOF Windows runner provides SDK 10.0.403, whose implicit ILLink
reference is 10.0.14; locked restore failed against the 10.0.12 entries
the 10.0.401 pin produced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:31:55 -06:00
James MaesandClaude Opus 5.5 3275d197e6 ci: build on the shared ci-workflows Windows workflow
windows / windows (push) Failing after 9s
build / windows (push) Failing after 8s
node / node (push) Successful in 22s
build / scripts (push) Successful in 22s
Locked restore, warnings-as-errors build, dotnet format, core tests with the
100% coverage gate and the PowerShell analyzer/Pester checks run on the KOF
Windows host runner; the review-gate scripts lint and test on node.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:30:38 -06:00
forge-recovery 2e940d078a build: pin SDK 10.0.401, the version the committed lock files require (#44)
KofTwentyTwo/ci-policy: ci / fork-guard (push) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / go-nix-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / gradle-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / gradle-3 (push) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (push) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (push) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (push) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (push) Skipped
KofTwentyTwo/ci-policy: ci / node-8 (push) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-5 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-4 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (push) Skipped
KofTwentyTwo/ci-policy: ci / not-enrolled (push) Skipped
KofTwentyTwo/ci-policy: native / fork-deny (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-0 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-2 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-3 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-4 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-5 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-6 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-7 (push) Skipped
KofTwentyTwo/ci-policy: Shared source and config CI / verify (push) Successful in 14s
KofTwentyTwo/ci-policy: Shared source and config CI / secrets (push) Successful in 17s
KofTwentyTwo/ci-policy: Shared .NET CI / secrets (push) Successful in 17s
KofTwentyTwo/ci-policy: Shared source and config CI / gate (push) Successful in 13s
KofTwentyTwo/ci-policy: ci / source-config-13 (push) Successful in 33s
KofTwentyTwo/ci-policy: ci / ci-result (push) Successful in 4s
KofTwentyTwo/ci-policy: Shared .NET CI / verify (push) Successful in 1m31s
KofTwentyTwo/ci-policy: Shared .NET CI / gate (push) Successful in 9s
KofTwentyTwo/ci-policy: native / dotnet-1 (push) Successful in 1m41s
KofTwentyTwo/ci-policy: native / native-result (push) Successful in 4s
2026-11-01 22:46:07 +00:00
james.maesandCodex GPT-6 a44449b991 feat(releases): ship MSI and reuse exact-source CI proof (#40)
Stable1.0.2 and dev1.0.2-beta.1 EXEs/ZIPs are public and their actual
downloaded signatures, timestamps, payloads, hashes, SBOM/provenance and
updater feeds verify. Complete the owner's renewed MSI request by
pinning standards1.2.0 at0ef3d319, enabling signed per-user MSI
alongside EXE/ZIP, preparing1.0.3 channel links and next development
prefix1.0.4.

Stop duplicate release validation: a tag must be on main, have a
verified source signature, match the entire genuinely merged PR Git
tree, and reuse that head's latest successful required technical checks
under their configured provider identities. Missing, failed, stale,
pending or different-tree proof fails publication. All branch technical
gates remain required; fresh release build/core coverage, signing, exact
MSI payload/native lifecycle and distribution/SBOM/provenance checks
remain required. Application behavior/dependencies/Azure trust are
unchanged.

Validation: all8 script tests pass (including10 release-proof boundary
cases), live source proof verifies all17 genuine checks on the actual
published18d8ae9 tree, five actual documents pass canonical lint,
actionlint/zizmor and signed commit hooks pass. Exact-head full CI
starts with this PR; production signed MSI lifecycle is still required
before delivery. No claim of published MSI yet.

Owner explicitly authorized release without further human-review
requests. Record a truthful release-specific waiver; never fabricate
personal-read acknowledgement; restore original rules immediately after
the bounded merge. Substantial AI assistance: Codex GPT-6. Refs
#33,#36,#38 and KofTwentyTwo/standards#47.

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex GPT-6 <noreply@openai.com>
2026-10-13 09:04:32 -05:00
james.maesandCodex GPT-6 18d8ae950c fix(releases): pin corrected signing builder and prepare downloads (#39)
Both1.0.1 release candidates passed acceptance and Azure login but
stopped before signing because of the shared inline PowerShell wrapper.
Pin the corrected, personally approved standards1.1.2 builder at083d903.
Preserve failed tags; use new1.0.2 stable/dev refs, prepare direct
EXE/ZIP download links, correct portable-update guidance and developer
verification links, and set the next development prefix to1.0.3.
Application behavior remains the reviewed and fully accepted PR37
source.

Validation: five actual Markdown files pass canonical lint; actionlint
and signed-commit hooks pass. Exact-head full hosted checks are running.
Production publication and downloaded-artifact signature/provenance
verification remain required. MSI remains next release per the owner's
latest decision. OpenSSF remains70%, not passing.

The owner expressly authorized completion without further human-review
requests. This is a release-specific waiver, not a fabricated
personal-read acknowledgement. Preserve all technical/security/signature
gates. Substantial AI assistance: Codex GPT-6. Refs #33, #34 and
KofTwentyTwo/standards#45.

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex GPT-6 <noreply@openai.com>
2026-10-13 06:48:03 -05:00
21 changed files with 737 additions and 120 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
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
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
/** Requires genuine passing CI on the exact complete tree merged for this release. */
async function run({ github, context, core })
{
const repository = context.repo;
const { data: source } = await github.rest.repos.getCommit({ ...repository, ref: context.sha });
if(!source.commit.verification.verified)
{
throw new Error("The release commit signature is not verified.");
}
const pulls = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit,
{ ...repository, commit_sha: context.sha, per_page: 100 });
const merged = pulls.filter(pull => pull.merged_at && pull.state === "closed"
&& pull.base.ref === "main" && pull.merge_commit_sha === context.sha);
if(merged.length !== 1)
{
throw new Error("Expected one merged main PR for the tagged release commit.");
}
const pull = merged[0];
const { data: tested } = await github.rest.repos.getCommit({ ...repository, ref: pull.head.sha });
if(tested.commit.tree.sha !== source.commit.tree.sha)
{
throw new Error("The release tree differs from the tested PR tree.");
}
const { data: rules } = await github.request("GET /repos/{owner}/{repo}/rules/branches/{branch}",
{ ...repository, branch: "main" });
// Personal acknowledgement is enforced at merge; technical validation is reused here.
const required = rules.filter(rule => rule.type === "required_status_checks")
.flatMap(rule => rule.parameters.required_status_checks)
.filter(check => check.context !== "review / automated");
if(required.length === 0)
{
throw new Error("No required technical checks were found.");
}
const checks = await github.paginate(github.rest.checks.listForRef,
{ ...repository, ref: pull.head.sha, per_page: 100 });
for(const requirement of required)
{
const records = checks.filter(check => check.name === requirement.context
&& check.app?.id === (requirement.integration_id ?? 15368));
records.sort((left, right) => right.id - left.id);
if(records[0]?.status !== "completed" || records[0]?.conclusion !== "success")
{
throw new Error(`Required tested-source check is not successful: ${requirement.context}`);
}
}
core.info(`Reused ${required.length} genuine passing checks from PR #${pull.number} at ${pull.head.sha}; exact release tree ${source.commit.tree.sha}.`);
return { head: pull.head.sha, tree: source.commit.tree.sha, checks: required.length };
}
module.exports = { run };
@@ -0,0 +1,43 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { run } = require("./assert-release-source.cjs");
/** Exercises release-tree, check-provider and latest-result boundaries. */
test("reuses only verified identical-source passing CI", async () =>
{
for(const failure of [null, "signature", "tree", "merge", "provider", "pending", "failed", "missing", "latest", "rules"])
{
const check = { id: 1, name: "ci / ui-tests", app: { id: failure === "provider" ? 7 : 15368 },
status: failure === "pending" ? "in_progress" : "completed",
conclusion: failure === "failed" ? "failure" : "success" };
const github = {
rest: {
repos: { getCommit: async ({ ref }) => ({ data: { commit: {
verification: { verified: failure !== "signature" },
tree: { sha: failure === "tree" && ref === "head" ? "other" : "tree" },
} } }), listPullRequestsAssociatedWithCommit: "pulls" },
checks: { listForRef: "checks" },
},
request: async () => ({ data: failure === "rules" ? [] : [{ type: "required_status_checks",
parameters: { required_status_checks: [{ context: "ci / ui-tests", integration_id: 15368 }, { context: "review / automated" }] } }] }),
paginate: async method => method === "pulls" ? (failure === "merge" ? [] : [{ number: 1, state: "closed",
merged_at: "today", base: { ref: "main" }, merge_commit_sha: "release", head: { sha: "head" } }])
: failure === "missing" ? [] : failure === "latest" ? [check, { ...check, id: 2, conclusion: "failure" }] : [check],
};
const operation = run({ github, context: { repo: { owner: "owner", repo: "repo" }, sha: "release" }, core: { info: () =>
{} } });
if(failure)
{
await assert.rejects(operation);
}
else
{
assert.deepEqual(await operation, { head: "head", tree: "tree", checks: 1 });
}
}
});
+11 -33
View File
@@ -38,6 +38,8 @@ jobs:
environment: release
permissions:
contents: read
checks: read
pull-requests: read
steps:
- name: Checkout main and tag history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -61,41 +63,16 @@ jobs:
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
run: ./build/Assert-ReleaseSigning.ps1
gates:
needs: verify
uses: ./.github/workflows/ci.yml
permissions:
contents: read
security:
needs: verify
uses: ./.github/workflows/security.yml
permissions:
contents: read
security-events: write
codeql:
needs: verify
uses: ./.github/workflows/codeql.yml
permissions:
contents: read
security-events: write
scripts:
needs: verify
uses: ./.github/workflows/scripts.yml
permissions:
contents: read
fuzzing:
needs: verify
uses: ./.github/workflows/fuzz.yml
permissions:
contents: read
- name: Require passing CI for the exact merged release tree
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { run } = require('./.github/scripts/assert-release-source.cjs');
await run({ github, context, core });
release:
needs: [verify, gates, security, codeql, scripts, fuzzing]
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@96cbdf208a15c9c524676bd15ff9e323228a952b # v1.1.1
needs: verify
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations; Azure OIDC when signing
@@ -113,6 +90,7 @@ jobs:
test-projects: |
tests/MarkdownViewr.Windows.Core.Tests
sign: true
msi: true
signing-files: |
app/MarkdownViewr.Windows.exe
app/MarkdownViewr.Windows.dll
+1 -1
View File
@@ -54,7 +54,7 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>1.0.0</VersionPrefix>
<VersionPrefix>1.0.4</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
+7 -9
View File
@@ -4,9 +4,7 @@ Read Markdown comfortably, keep up with changes, and turn your notes into someth
MarkdownViewr is a dedicated Windows reader for project documentation, notes, and README files. Open a document, choose a look you like, and focus on the words.
**[Release downloads](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases) · [User guide](docs/user-guide.md) · [Ask a question](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions)**
> The first Windows release is being prepared. There are no published Windows downloads yet; the Releases page will contain the stable installer and development previews when publication completes.
**[Download Windows installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Setup.exe) · [User guide](docs/user-guide.md) · [Ask a question](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions)**
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15417/badge)](https://www.bestpractices.dev/en/projects/15417)
@@ -29,17 +27,17 @@ Contents and Source start hidden. Turn them on when needed, or change their new-
## Download and install
The planned Windows packages support **Windows 10 version 1809 or later and Windows 11, x64**. Microsoft Edge WebView2 Runtime is required. No .NET SDK is needed to use a release.
Windows packages support **Windows 10 version 1809 or later and Windows 11, x64**. Microsoft Edge WebView2 Runtime is required. No .NET SDK is needed to use a release.
| Edition | Choose it for | Package |
| --- | --- | --- |
| Stable | Everyday reading | `markdownviewr-windows-stable-Setup.exe` |
| Development preview | Trying upcoming changes | `markdownviewr-windows-dev-Setup.exe` |
| Portable | Running without installation | `markdownviewr-windows-stable-Portable.zip` |
| Stable | Everyday reading | [Download EXE installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Setup.exe) · [MSI installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable.msi) |
| Development preview | Trying upcoming changes | [Download dev EXE installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev-Setup.exe) · [MSI installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev.msi) |
| Portable | Running without installation | [Download portable ZIP](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Portable.zip) |
Once available, download a package from [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases). Run Setup for a per-user installation without administrator rights, or extract the portable ZIP before launching the app.
Download a package from [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases). Run Setup for a per-user installation without administrator rights, or extract the portable ZIP before launching the app.
Installed editions check for updates through **Help → Check for updates**. Stable and development editions keep separate settings and update channels, so they can coexist. Portable copies do not self-update.
Installed and portable editions check for updates through **Help → Check for updates**. Stable and development editions keep separate settings and update channels, so they can coexist.
## Quick start
+2 -3
View File
@@ -50,11 +50,10 @@ minor receives security fixes for 6 months.
Public Windows releases require Authenticode signing of the final shipped binaries
and installers. Publication fails closed until the approved Azure signing identity
and expected publisher, trust and timestamp are verified. No verified signed
MarkdownViewr release has been published yet; unsigned test fixtures are not releases.
and expected publisher, trust and timestamp are verified. Unsigned test fixtures are not releases.
Verify final signatures, `SHA256SUMS` and provenance attestations as described in the
[README](README.md#verifying-a-release) and
[Developer Guide](DEVELOPERS.md#releases-and-verification) and
[release signing instructions](docs/release-signing.md). No historical exception
is claimed as permission to publish unsigned application releases.
+24 -38
View File
@@ -1,49 +1,35 @@
# Session State
Updated: 2026-10-12
Updated: 2026-10-13
## Release blockers after main merge - 2026-10-12
## Release source and verification
Standards PR44 was personally reviewed by the owner and merged atb3e4b49.
Its version/changelog-only release PR42 merged at96cbdf2; signedv1.1.1 was pushed
and release CI38322016784 is running. The app caller now pins that exact builder.
No new Azure administration or credentials are required.
Stable1.0.2 and dev1.0.2-beta.1 EXE/ZIP releases are public and immutable at
source18d8ae950cab445672d216d387a956b964d2c6c1, builder083d903. Actual downloaded
signatures/publisher/trust/timestamps, package payloads, checksums, CycloneDX
SBOM attestations, source/builder provenance and updater feeds verify.
Production runs38392743207 and38392743312 succeeded. Failed older refs remain.
App PR37's first hosted run completed111 published tests:110 passed, one literal
keyboard assertion observedjk instead ofjkg. The test now waits for the exactjkg
value within its existing10-second deadline before retaining the same assertion.
FlaUI5.0.0's UntilInputIsProcessed is only a100ms sleep, not an acknowledgement.
The exact test is also included in the focused hosted regression job. Builds and
formatting pass; hosted verification remains required. Local baseline and changed
runs both stopped earlier at missing FindText, so they do not verify thejkg fix.
Temporary diagnostics were removed. Original preview processes/profiles remain
untouched. Fresh exact-head hosted acceptance must pass before merge/publication.
The maintainer renewed the MSI request on2026-10-13; earlier MSI deferral is
superseded. Prepare signed per-user MSI alongside EXE/ZIP for stable/dev1.0.3.
MSI payload verification and focused native install/launch/uninstall must pass
before publication. Source behaviors, dependencies and Azure trust are unchanged.
The owner-reviewed application merged through PR35 at38dd5c1. Main CI38294895221
passed; retained published111 and installed stable/dev110 reports have zero
failures/skips. OpenSSF project15417 assessment is saved at70%, not passing.
The exact Syft image is now explicitly owner-approved and allowlisted.
Prevent duplicate release test passes by requiring the exact complete Git tree
of a genuinely merged main PR whose latest required technical checks all passed
under their required provider identities. Reject unverified source signatures,
different trees, missing/failed/pending checks and API failures. Reuse real CI
proof; do not post fabricated results or weaken branch protection. Fresh release
build/core coverage, signing and installer/distribution checks remain required.
Neither production release is published. Signedv1.0.0 andv1.0.0-beta.1 tags remain
preserved. Dev38299469062 passed acceptance and Azure OIDC login, then failed the
official signing action dependency install because --add-source conflicts with
NuGet package source mapping. Standards PR44 stages the exact verified dependency
without changing caller NuGet policy;40 signing tests and the real package bootstrap
pass locally. Production CI signing and final artifact verification remain pending.
The owner explicitly authorized finishing releases without further human-review
requests. Record the release-specific waiver truthfully; restore the original
manual-review requirement immediately after each authorized merge. The next
development prefix is1.0.4. OpenSSF15417 remains70%, not passing. Preserve owner
previews/profiles and unrelated worktrees. Track actual publication/proof in
[release checklist](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/33).
Stable38299469141 attempt2 completed110 of111 published tests without a reported
test failure before its600-second process limit. The successful main published
suite took572 seconds. The bounded suite budget is now900 seconds; individual
test deadlines, assertions, cases and failure handling remain unchanged. The job
budget allows all three suites plus packaging/update probes. Two real subprocess
regressions verify that hangs and nonzero exits still fail the gate. Fresh hosted
acceptance is required; this timing change is not proof of a successful release.
The owner's latest instruction is EXE/portable ZIP first, MSI next release (#36).
Do not cancel runs, alter published assets, replace existing release tags, skip
acceptance or claim public downloads. Next: genuine review and green checks for
the two fixes, merge the shared builder, update the app's immutable builder pin,
then publish new signed release candidates and verify the downloaded artifacts.
## Historical release snapshots
## Current release status - 2026-10-12
+8 -15
View File
@@ -1,21 +1,14 @@
# Development Tasks
## Current release checklist - 2026-10-12
## Release and follow-up work
- [x] Merge genuinely owner-reviewed app through PR35 at38dd5c1; all main gates pass.
- [x] Preserve signed1.0.0/stable and1.0.0-beta.1/dev tags; no release is published.
- [x] Apply only the exact Syft image addition explicitly approved by the owner.
- [x] Prove production Azure OIDC login; preserve existing account/profile/trust.
- [x] Reproduce signing dependency conflict; standards PR44 real package bootstrap and40 signing tests pass locally.
- [x] Measure stable retry110/111 without reported test failures before600-second limit; prepare bounded900-second suite budget with unchanged assertions/deadlines.
- [ ] Obtain genuine reviews, pass exact-head hosted checks and merge the release fixes.
- [x] Owner personally reviewed standards PR44; merge the signing fix and version-only PR42 through normal protections, then push signedstandards1.1.1.
- [x] Update the app caller to fixed builder96cbdf2; preserve caller NuGet mapping and strict signing verification.
- [x] Keep the exactjkg assertion while awaiting native text processing within its existing deadline; add the case to focused hosted regressions.
- [ ] Verify fresh full hosted acceptance and obtain genuine final-head app review before merge.
- [ ] Publish stable/dev EXE and ZIP candidates; verify exact distributed signatures, timestamps, hashes, SBOMs and attestations (#33).
- [ ] Update user download links only after actual verified publication.
- [ ] Deliver signed and acceptance-tested MSI installers in the next release (#36), following the owner's latest decision.
- [x] Publish/verify signed stable1.0.2 and dev1.0.2-beta.1 EXE/ZIP releases (#33).
- [x] Prepare exact-tree/provider-bound reuse of genuine source CI; boundary tests and real17-check source proof pass (#38).
- [ ] Publish the native MSI builder, pin it and enable signed MSI in the caller (#36).
- [ ] Pass exact-head CI, record the existing release waiver, merge and restore all original branch checks.
- [ ] Publish1.0.3 stable/dev MSI+EXE+ZIP; verify actual MSI install/launch/uninstall and all final payload signatures, hashes, SBOMs and provenance.
- [x] Set next development prefix to1.0.4 and prepare user download links.
- Complete remaining OpenSSF evidence; saved70% is not a passing badge (#31).
## Prior release checklist
+5 -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; performance acceptance remains |
| 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 |
| 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 |
@@ -426,3 +426,7 @@ actual parsers. Independent properties detect unsafe acceptance as well as crash
Synthetic inputs never execute scripts, start native processes or touch real profiles.
The record in [assurance.json](assurance.json) does not claim coverage-guided
instrumentation, Best Practices enrollment or verified production signatures.
## Release validation reuse
Release tags must be on main and have a verified commit signature. Reused source CI must belong to the genuinely merged main PR, match the entire tagged Git tree and have successful latest required technical checks from the configured provider identities. Missing, stale, failed or pending proof fails publication. This avoids repeating identical UI tests; it does not trust arbitrary PR artifacts or remove branch checks. Fresh build/coverage, Azure signing, MSI installation and exact distributed-payload verification remain release gates.
+5 -1
View File
@@ -60,7 +60,7 @@ The installer preserves your current defaults. Windows requires you to choose th
## Updates and local data
Installed stable and development editions update within their own channels using **Help → Check for updates**. They have separate settings, recent files, and logs. Portable copies require a fresh download.
Stable and development editions, including portable copies, update within their own channels using **Help → Check for updates**. They have separate settings, recent files, and logs.
Settings and logs are stored locally under `%LOCALAPPDATA%\markdownviewr-windows` or `%LOCALAPPDATA%\markdownviewr-windows-dev`. **Help → Activity log** opens the log viewer and provides a shortcut to its folder.
@@ -75,3 +75,7 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md).
## MSI installation
The MSI installs for the current Windows user, alongside the EXE and portable download options. Stable and dev have separate package identities. Use the same installer format for an existing edition; install the other channel separately to try previews. Uninstall an MSI installation through Windows Settings → Apps → Installed apps. The installer registers supported Markdown types and preserves your existing default choices.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"version": "10.0.403",
"rollForward": "latestFeature"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
},
"scripts": {
"lint": "eslint .github/scripts eslint.config.mjs --max-warnings 0",
"test": "node --test .github/scripts/review-gate.test.cjs"
"test": "node --test .github/scripts/review-gate.test.cjs .github/scripts/assert-release-source.test.cjs"
},
"devDependencies": {
"@eslint/js": "10.0.1",
@@ -18,16 +18,25 @@ public sealed class DocumentOutline
/// <summary>Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.</summary>
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide, MarkdownExtensions? extensions = null)
/// <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)
{
var sources = new DocumentSourceMap(markdown);
var sourceHeadings = new List<DocumentSourceHeading>();
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources));
cancellation.ThrowIfCancellationRequested();
string rendered = new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources);
cancellation.ThrowIfCancellationRequested();
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(rendered);
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))
@@ -37,11 +46,15 @@ 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;
for(int suffix = 1; fragments.ContainsKey(fragment); suffix++)
int suffix = nextSuffix.GetValueOrDefault(slug, 1);
while(fragments.ContainsKey(fragment))
{
fragment = slug + "-" + suffix.ToString(CultureInfo.InvariantCulture);
suffix++;
}
nextSuffix[slug] = suffix;
fragments.Add(fragment, element.Id);
}
foreach(IElement element in body.QuerySelectorAll("[" + sources.AttributeName + "]"))
@@ -56,6 +69,7 @@ public sealed class DocumentOutline
link.SetAttribute("href", "#" + target);
}
}
cancellation.ThrowIfCancellationRequested();
foreach(IElement element in body.QuerySelectorAll("*").Reverse())
{
if(!_elements.ContainsKey(element))
@@ -142,10 +142,12 @@ 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')
{
@@ -209,11 +211,13 @@ 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)
@@ -0,0 +1,90 @@
/*
* 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,6 +6,8 @@
using System.Text;
using System.Text.RegularExpressions;
using AngleSharp;
using AngleSharp.Css.Parser;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
@@ -24,6 +26,14 @@ 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;
@@ -100,7 +110,23 @@ public sealed class MarkdownRenderer
_ => "",
} : "";
string html = sources is null ? RenderDocument(Markdown.Parse(body, _pipeline)) : sources.Render(body, _pipeline, markdown.Length - body.Length, RenderDocument);
return _sanitizer.Sanitize(prefix + html);
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);
}
@@ -124,21 +150,26 @@ 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)
{
string text = html[range.Start.Index..range.End.Index];
// 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 formatted = FormatRawText(text, extendSubscript);
if(!string.Equals(text, formatted, StringComparison.Ordinal))
{
changes.Add((range.Start.Index, range.End.Index, formatted));
changes.Add((start, end, formatted));
}
}
},
@@ -148,13 +179,7 @@ public sealed class MarkdownRenderer
{
continue;
}
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');
string[] lines = Rewrite(html, changes).Split('\n');
for(int index = 0; index < block.Lines.Count; index++)
{
block.Lines.Lines[index].Slice = new Markdig.Helpers.StringSlice(lines[index]);
@@ -165,6 +190,28 @@ 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)
{
@@ -198,10 +245,12 @@ 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.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
"requested": "[10.0.14, )",
"resolved": "10.0.14",
"contentHash": "YlTamXppEQls6DYWt7RdUuwWDb0lyw/vXCaGeQpLCpsLmfwGuCkOz/vIGgMTV7ECsJcavkCOkxZXv9s8rdAHkQ=="
},
"AngleSharp": {
"type": "Transitive",
@@ -0,0 +1,218 @@
/*
* 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));
}
@@ -0,0 +1,126 @@
/*
* 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);
}
}
@@ -150,6 +150,12 @@ public partial class AppTests
CloseSettings(session);
InvokeDocumentMenu(session, sibling, "SourceToggle", menuName: "View");
AssertSavedPrintingDefaults(session, options);
session.WaitFor(() =>
{
string settings = session.ReadSettings();
using var saved = JsonDocument.Parse(settings);
return saved.RootElement.GetProperty("ShowSource").GetBoolean() ? sibling : null;
}, "the sibling source toggle to finish saving before the print-cancel snapshot");
string savedSettings = session.ReadSettings();
InvokeDocumentMenu(session, primary, "PrintMenuItem");
AssertPrintOptionControls(session, options);