feat(reader): open and render isolated Markdown documents

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-10 19:35:37 -05:00
1 parent 85004f1d1f
commit f1756d21ed
35 files changed
+1356 -602

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
## Purpose and Structure
MarkdownViewr.Windows is a Windows Markdown viewer built with AppKit and WinUI 3. Read `docs/PRODUCT.md`, `docs/TODO.md` and `docs/SESSION-STATE.md` before continuing. The initial greeting is scaffold content; do not claim the viewer is implemented until its requirements pass.
MarkdownViewr.Windows is a Windows Markdown viewer built with AppKit and WinUI 3. Read `docs/PRODUCT.md`, `docs/TODO.md` and `docs/SESSION-STATE.md` before continuing. The first reader is implemented; full parity and desktop acceptance remain pending. Do not claim requirements passed without evidence.
`src/MarkdownViewr.Windows.Core/` contains testable logic and view models. `src/MarkdownViewr.Windows/` contains WinUI views and Windows adapters; brand assets are in `Assets/Brand/`. Unit, desktop UI and installed-update probes live under `tests/`. `build/` contains coverage, packaging, setup and release validation tools.
+2 -1
View File
@@ -21,7 +21,8 @@
<ItemGroup>
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Markdig" Version="1.4.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.2.1039" />
</ItemGroup>
<ItemGroup Label="Tests">
+6 -4
View File
@@ -1,6 +1,6 @@
# MarkdownViewr
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The application currently shows the starter's greeting screen; Markdown viewing is not implemented yet. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual reload. Live reload, document themes, navigation and printing are still in development; desktop acceptance is pending. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
A Windows Markdown viewer with live reload, themes and document navigation.
@@ -15,6 +15,8 @@ A Windows Markdown viewer with live reload, themes and document navigation.
## Install
No Windows release has been published. The following describes the planned release artifacts.
Download `markdownviewr-windows-stable-Setup.exe` from the
[latest release](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest) and run it. It
installs per user (no administrator rights needed) and updates itself in place from
@@ -26,9 +28,8 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
## Usage
- **Greeting (example feature):** type a name and press **Greet**. The name is remembered
for next time. This is the template's example of a feature: logic and state in
`MarkdownViewr.Windows.Core`, a thin view in `MarkdownViewr.Windows`.
- **File → Open / Ctrl+O:** choose a Markdown document. A literal file path can also be supplied when launching the executable.
- **File → Reload:** reread the current file. Local images stay within the document folder; remote images and authored scripts/styles are blocked.
- **View:** choose the light, dark, or system theme; turn the startup splash and the
startup update check on or off. Choices are saved immediately.
- **Help → Activity log:** a live view of the app's log, with an errors-only filter and a
@@ -44,6 +45,7 @@ different folder, for example a portable one.
Prerequisites:
- Windows 10 version 1809 or later, x64 or ARM64
- Microsoft Edge WebView2 evergreen Runtime
- .NET 10 SDK (the feature band is pinned by `global.json`)
- Optionally Visual Studio 2026 (Windows App SDK workload) or Rider
+109
View File
@@ -0,0 +1,109 @@
# Third-party rendering libraries
These notices accompany the rendering libraries shipped with MarkdownViewr.
## Markdig 1.4.0
Source: <https://github.com/xoofx/markdig>
Copyright (c) 2016-2026, Alexandre Mutel
All rights reserved.
Redistribution and use in source and binary forms, with or without modification
, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## HtmlSanitizer 9.2.1039
Source: <https://github.com/mganss/HtmlSanitizer>
The MIT License (MIT)
Copyright (c) 2013-2016 Michael Ganss and HtmlSanitizer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## AngleSharp 1.7.2
Source: <https://github.com/AngleSharp/AngleSharp>
The MIT License (MIT)
Copyright (c) 2013 - 2026 AngleSharp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## AngleSharp.Css 1.0.2
Source: <https://github.com/AngleSharp/AngleSharp.Css>
The MIT License (MIT)
Copyright (c) 2013 - 2026 AngleSharp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -2
View File
@@ -2,7 +2,7 @@
Build a Windows counterpart to [Darin Kelkhoff's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), using the KofTwentyTwo AppKit foundations and standards. The agreed display name is MarkdownViewr, repository MarkdownViewr.Windows, and permanent application ID markdownviewr-windows.
The current tree is a generated, building AppKit scaffold. Its greeting screen remains a sample; it is not yet a Markdown viewer. The following features are requirements, not implemented claims.
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports manual reload and bounded local images. Desktop acceptance is pending. The full feature list below remains the target; the first reader is not a completed port.
## Reference and feature scope
@@ -25,7 +25,7 @@ Reference source inspected at commit `6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021`.
## Proposed implementation for discussion
Use a WinUI 3 shell with a WebView2 document surface, a pure .NET core for document/rendering state, and thin Windows adapters for file activation, watching, editor launch and printing. Evaluate a maintained .NET Markdown parser against the original's actual output before choosing it. Reuse compatible original HTML/CSS/navigation resources where useful, retaining attribution and replacing the macOS message bridge.
Use a WinUI 3 shell with a WebView2 document surface, a pure .NET core for document/rendering state, and thin Windows adapters for file activation, watching, editor launch and printing. Markdig and HtmlSanitizer implement the first GFM and passive-markup boundary; see ADR-0002. Reuse compatible original HTML/CSS/navigation resources where useful, retaining attribution and replacing the macOS message bridge.
Treat document Markdown, HTML, images, CSS, links and web messages as untrusted. Document content must not execute arbitrary script or gain access to native host commands. Local-resource access must remain bounded to explicitly allowed document resources; remote navigation and external editor launch require deliberate user actions. Define and test the renderer policy before loading user files.
+43 -33
View File
@@ -2,41 +2,51 @@
Updated: 2026-10-10
## Published scaffold checkpoint
## First reader implementation
Active project: MarkdownViewr.Windows. Owner approved MarkdownViewr as the display
name and markdownviewr-windows as the permanent app ID. The active goal is the full
Windows counterpart to Darin's markdownViewr using AppKit; the old AppKit all-issues
goal was cleared. Work is on feat/windows-viewer, draft PR #3, issues #1 and #4.
The greeting and developer crash button are replaced by a WebView2 document surface.
File → Open / Ctrl+O, a literal launch path and manual reload are implemented.
Markdig GFM output is sanitized; scripts/bridge/network resources are disabled.
Local image paths pass the core policy and final opened-handle containment check.
Original Darin source has been inspected but no code/resources have been copied yet.
ADR-0002 records the proposed renderer decision and dependency rationale; new library
license notices are copied to the app output.
Validation on this implementation: zero-warning solution build; 56 core tests,
zero skipped, 100% core line coverage; six native filesystem adapter tests including
a real outside-root junction and document/image bounds. The prior 71-test result
included 15 now-removed greeting tests. Keep actual counts clear.
Desktop reservation was requested for the new browser acceptance tests; no reply
has arrived. Do not run input tests until the desktop is available. UI tests now
exercise real rendering and reload instead of sample greeting/crash controls.
Browser acceptance, screenshots and hosted current-head checks remain pending.
## Repository and release state
Public repository: [MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
Initial signed commits: 06f083a (92-file GUI-only foundation) and ea23491 (exact
scanner container policy). Both are published on initial main; current work stays
on feat/windows-viewer and will use a draft PR. Implementation tracked by issue #1.
Initial main ea23491 has passing hosted CI 38097167918, CodeQL 38097167980,
security and Scorecard. Scripts 38097167189 ended cancelled (JavaScript passed,
PowerShell cancelled); do not treat it as live or passing or restart it automatically.
Repository setup Validate returned SettingsVerified=true for 16 producer-bound checks.
Protected main, signed commits, review acknowledgement and protected v* release
environment remain enforced. Never fabricate human review.
All 13 restrictive setup requests were applied. Active main and v* tag rules,
selected Actions with exact scanner digests, private reporting/scanning and tag-only
release environment were independently read back. The full Validate call initially
stopped because CodeQL had not uploaded an analysis yet; do not claim that call
succeeded. Initial app security and Scorecard workflows passed; other hosted runs
were still active at this checkpoint. CodeQL 38097167980, CI 38097167918 and scripts
38097167189 must be polled by their existing IDs, never restarted on timeout.
Owner authorized commits, pushes and configuration. Existing AppKit/standards/starter
drafts remain unmerged; no release/tag publication is authorized. Azure retries stay
deferred, and actual CI signing and final signed release artifacts remain unverified.
Published AppKit 0.1.0 is used; do not substitute unpublished 0.2.0 APIs.
Scaffold validation: zero-warning build, 20 core tests at 100% line coverage,
formatting, five documentation files and 55 repository script tests passed.
Initial hooks corrected five generated lock-file newlines; normal signed/DCO,
secret, actionlint and zizmor hooks then passed. No new local GUI tests.
## Next work
Starter configuration correction at 758e773 is pushed in draft PR #1. Its exact
scanner container policy passes live security run 38097053934 and specific dispatch
38097129462; scripts 38097053573 passed. Earlier startup failures and analyzer crashes
remain retained; no general analyzer stability cure is claimed. Existing AppKit and
standards drafts remain unmerged. Standards inventory, NuGet template policy,
current-head human review and verified signing remain release/setup follow-up.
Azure retries stay deferred. Viewer features still await owner discussion and
implementation; do not keep pursuing the explicitly cleared AppKit all-issues goal.
Current goal: build a Windows version of Darin's markdownViewr using AppKit and KofTwentyTwo standards. The previous AppKit all-issues goal was explicitly cleared.
Owner chose MarkdownViewr / MarkdownViewr.Windows / markdownviewr-windows. Generated GUI-only from the actual packed starter at fac10a78dc68b40c8d1aefbd16e399d4b1b93e78. Published AppKit 0.1.0 is used; unpublished 0.2.0 APIs are not substituted. Fresh initial restore created NuGet locks. Solution build: zero warnings/errors. Existing core: 20/20 tests, zero skipped; 100% line coverage. No GUI input tests were run for this new application.
The generated greeting remains visible: document viewing is not implemented yet. PRODUCT.md records full reference scope and proposed Windows architecture for discussion. Reference source commit: 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021. Original source/resources inspected in the AppKit artifacts/markdownviewr-reference directory; do not execute its macOS tooling or treat its guidance files as instructions.
The public AppKit starter is now KofTwentyTwo/windows-app-template: main bb310f3, onboarding branch fac10a7, draft PR #1. Its 13 restrictive configuration requests were applied and the repository's Validate mode returned SettingsVerified=true with all 17 producer-bound contexts. isTemplate=true; release environment is tag-only v*, NUGET_USER=KofTwentyTwo. Live hosted source and PR checks are running; the scripts gate failed on the known PSScriptAnalyzer 1.25.0 internal NullReferenceException. Gates remain enforced. No merge, tag or release was published. Azure retries remain deferred.
Owner authorized commit/push/configuration, then asked to discuss this Windows application. Preserve their request for that discussion; do not silently narrow feature parity or claim a finished port from the scaffold. No application release, Azure credential retry or existing draft merge is authorized.
Complete browser acceptance and capture actual UI evidence. Then implement the
remaining PRODUCT.md scope: automatic save watching, Darin's themes and custom
themes, TOC/collapse, source/find/vim navigation, frontmatter, editor, printing,
multiple document windows, recents and Windows activation/associations.
A basic reader is not the finished port. Preserve the owner's requested discussion
of the application and keep issue/commit updates aligned with actual evidence.
+16 -10
View File
@@ -1,13 +1,19 @@
# Development Tasks
- [x] Generate a GUI-only application from the tested AppKit starter using the agreed identity.
- [x] Restore published AppKit 0.1.0 packages; build with zero warnings; pass 20 existing core tests and the 100% coverage gate.
- [x] Inspect pinned markdownViewr source and capture the full Windows feature requirements in PRODUCT.md.
- [x] Generate and publish the GUI-only AppKit application with the approved identity.
- [x] Configure/read back repository protections and establish the initial hosted CI baseline.
- [x] Inspect pinned markdownViewr source and record the full parity scope in PRODUCT.md.
- [x] Implement GFM parsing, passive HTML sanitation and the isolated resource policy (ADR-0002).
- [x] Replace the greeting/crash controls with file opening, rendered Markdown and manual reload.
- [x] Test physical image containment, including junction escapes, and input size limits.
- [x] Update the threat model, renderer attributions and shipped library notices.
- [ ] Complete actual browser/desktop acceptance and screenshots for the first reader.
- [ ] Discuss the desired first release and Windows-specific behavior with the owner.
- [ ] Select and document the renderer and untrusted-document security policy.
- [ ] Replace the sample greeting with file opening and rendered Markdown.
- [ ] Implement and test all document, theme, navigation, source, find, frontmatter, editor and printing requirements in PRODUCT.md.
- [ ] Update the threat model, attribution, screenshots, user documentation and agent guidance as features land.
- [ ] Establish hosted repository gates and standards inventory; resolve inherited analyzer instability without suppressing errors.
- [ ] Adopt the reviewed/published structured AppKit API and signing builder when available.
- [ ] Verify actual signed stable/dev installers and the exact shipped bytes before any release publication.
- [ ] Add automatic reload on saves/atomic replacements with preserved scroll and deletion recovery.
- [ ] Port Darin's built-in themes; add editable user themes, custom CSS and code font settings.
- [ ] Add TOC, section collapse, source pane, synchronized navigation, find and vim keys.
- [ ] Add YAML frontmatter modes, safe configured editor launch and printing options.
- [ ] Add multiple document windows, recent files and Windows activation/file associations.
- [ ] Verify hosted checks on each actual implementation head; scripts baseline ended cancelled.
- [ ] Finish standards inventory and reviewed/published AppKit/signing API adoption when available.
- [ ] Verify actual signed stable/dev installers and exact shipped bytes before release publication.
@@ -0,0 +1,68 @@
# ADR-0002: GFM rendering inside an isolated WebView2 document surface
- **Status:** Proposed; implemented on the viewer topic branch, pending review
- **Date:** 2026-10-10
- **Decider:** Project maintainer
- **Tracking:** [reader milestone #4](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/4)
## Context
Darin's document layout needs HTML/CSS, GFM tables, tasks and useful raw HTML.
Documents can contain hostile markup, resource paths and links. A Markdown parser
alone does not provide a native application security boundary.
## Decision
Use Markdig 1.4.0 (BSD-2-Clause) for GFM and HtmlSanitizer 9.2.1039 (MIT) for
an explicit passive-markup allowlist. AngleSharp and AngleSharp.Css are their MIT
dependencies. Remove the unused sample greeting and CommunityToolkit dependency.
Versions and transitive hashes are committed in the NuGet locks.
Host the sanitized result in WinUI WebView2. Serve an intercepted logical HTTPS
document, with a random resource namespace for each open; no local HTTP listener.
The document response carries a deny-by-default CSP and no-referrer/no-store headers.
Only application-generated CSS is allowed; authored styles, IDs and classes are removed.
Disable scripts, web messages, host objects, frames, downloads, popups and permission
requests. Reject all browser resources except the exact document and allowlisted
local image requests. External HTTP(S) navigation requires a real user gesture
and opens in Windows' external handler. Other links remain blocked in this slice.
Decode resource paths once; reject traversal, alternate streams, devices and Windows
filename aliases. Before reading an image, resolve its opened handle with
GetFinalPathNameByHandleW and compare it against the physical document directory.
Read from that same handle, preventing a check/reopen race. Remote images are blocked.
Bound documents to 16 Mi characters and individual images to 16 MiB.
## Options considered
| Option | Benefit | Cost |
| --- | --- | --- |
| Markdig, sanitization and WebView2 | Maintained parsing; compatible HTML/CSS surface | Requires explicit browser/filesystem containment |
| Parser plus unrestricted HTML | Small initial implementation | Exposes script, resource and native navigation risks |
| Entirely native text layout | Avoids an embedded browser | Reimplements tables, themes, print and navigation |
## Consequences
The first slice supports reading, a picker, literal launch arguments and manual reload.
It does not yet reproduce Darin's themes, syntax colors or navigation. Any later trusted
JavaScript or native message bridge requires a revised CSP, origin/nonce/schema checks
and tests before enabling it. Custom CSS must not introduce network or file access.
## Verification and follow-up
Core regression tests cover GFM, hostile HTML and resource paths at 100% core line
coverage. Native adapter tests cover BOM decoding, byte limits, exact image bytes and
a real junction pointing outside the document folder. Browser/UI acceptance is a
separate gate; these unit tests do not prove browser behavior or release signing.
- [ ] Review this decision and complete document UI acceptance.
- [ ] Add save watching, navigation, themes, source/find and print from PRODUCT.md.
- [ ] Verify WebView2 runtime deployment and exact signed installed release artifacts.
## References
- [Markdig](https://github.com/xoofx/markdig)
- [HtmlSanitizer](https://github.com/mganss/HtmlSanitizer)
- [Microsoft WebView2 security guidance](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/security)
- [GetFinalPathNameByHandleW](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew)
+52 -84
View File
@@ -1,92 +1,60 @@
# Threat model: MarkdownViewr
<!-- K22-SDLC-03. Update in any pull request that changes the attack surface, and review
in every MINOR/MAJOR release checklist (K22-SEC-30). Method: data flow + STRIDE. -->
- **Version reviewed:** first reader implementation
- **Last reviewed:** 2026-10-10
- **Method:** data flow and STRIDE; review again for each attack-surface change.
- **Version reviewed:** template readiness work · **Last reviewed:** 2026-10-10
## Product and boundaries
## 1. What the product is and does
A per-user WinUI application reads user-selected local Markdown. AppKit supplies
settings, logging, crash handling and Velopack updates. The UI-free core parses and
sanitizes Markdown. WebView2 displays the sanitized result through an intercepted
HTTPS origin. No application HTTP server or scriptable command-line head exists.
A Windows desktop application installed per user (no administrator rights) with
Velopack, updating itself from its GitHub Releases. It runs entirely on the user's
machine: it keeps preferences and logs in the user's profile and has no server
component. The template's example feature (a greeting) handles only text the user types.
Document text, HTML, links, images and filesystem aliases are untrusted. The user
selects the document; this authorizes reading it and passive images within its
physical directory. It does not authorize native commands or remote resource access.
## 2. Actors and components
## Interfaces and controls
| Actor or component | Description | Trust level |
| Interface | Control |
| --- | --- |
| Picker / literal process argument | Read-only bounded snapshot; no shell interpolation |
| Markdown / raw HTML | Explicit tag/attribute/scheme allowlist; disabled checkbox inputs; no authored scripts, CSS, IDs or classes |
| Browser surface | Scripts, messages, host objects, permissions, downloads, frames and popups disabled; restrictive CSP |
| Local images | Per-document URI prefix; one decode; filename/extension policy; final open-handle containment; 16 MiB limit |
| Remote resources | Denied by CSP and resource interception |
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths |
| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight |
| Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token |
## Threats and verification
| Threat | Mitigation and evidence |
| --- | --- |
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser acceptance pending |
| 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; browser network acceptance pending |
| 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 |
## Remaining risks and work
This is a first reader, not a completed port. User themes, external editor commands,
save watching, print and any future browser bridge need their own controls and tests.
Local links to other documents and mailto links are currently blocked. WebView2 uses
the installed evergreen runtime; installed deployment acceptance remains required.
Historical EX-0002 does not authorize unsigned production publication. Azure setup
and real final-artifact signature validation are deferred; an active profile alone
does not establish working release signing.
## Review log
| Date | Reviewer | Changes |
| --- | --- | --- |
| User | The person running the app | Trusted for their own data |
| WinUI shell (`MarkdownViewr.Windows.exe`) | Views and startup plumbing | Runs as the user |
| Core (`MarkdownViewr.Windows.Core`) | Logic, settings, view models | Runs as the user |
| Command line (`markdownviewr-windows.exe`) | Optional scriptable head over Core | Runs as the user |
| AppKit packages | Shell services: logging, settings, secrets, updates, splash, About | Third-party code, pinned and attested |
| Velopack update service | Checks GitHub Releases, downloads and applies packages | Runs as the user |
| GitHub Releases | Update feed and download source | Untrusted network data |
## 3. Data flow and trust boundaries
```mermaid
flowchart LR
user([User]) --> shell[MarkdownViewr.Windows.exe]
user --> cli[markdownviewr-windows.exe]
shell --> core[MarkdownViewr.Windows.Core]
cli --> core
core --> data[("%LOCALAPPDATA%\markdownviewr-windows<br/>settings.json · logs")]
shell -- HTTPS --> gh[(GitHub Releases)]
subgraph machine [Trust boundary: the user's machine and account]
shell
cli
core
data
end
```
## 4. External interfaces
| Interface | Direction | Data | Authenticated? | Validated where? |
| --- | --- | --- | --- | --- |
| Window input (text boxes, menus) | In | User text | The signed-in user | `GreetingService.Greet` (length limit), each feature's Core service |
| Command line arguments | In | Command, options | The signed-in user | `CliApp.Run` (unknown commands and options rejected) |
| `settings.json` | In/out | Preferences | File ACLs of the user profile | `SettingsStore` sanitizes on load; corrupt files fall back to defaults |
| Log files | Out | Operational events, no secrets | File ACLs | Logging API takes messages only |
| Data-folder environment variable | In | A folder path | The signed-in user | Used as a path only |
| Update feed (GitHub Releases over HTTPS) | In | Release metadata and packages | TLS; package hashes checked by Velopack | Velopack |
| Windows Credential Manager | In/out | Secrets, if a feature stores any | The user's Windows login | AppKit `CredentialManagerVault` |
## 5. Assets
The integrity of the installed program and its updates (an attacker who controls them
runs code as the user), the user's preferences and any stored secrets, and availability
of the app.
## 6. Threats (STRIDE)
| # | Boundary / component | Category | Threat | Likelihood | Impact | Mitigation | Status |
| --- | --- | --- | --- | --- | --- | --- | --- |
| T1 | Update feed | Tampering | A malicious update is served to installed copies | Low | Critical | Updates only from this repository's immutable, attested GitHub Releases over HTTPS; Velopack verifies package hashes; Authenticode signing once EX-0002 closes | Mitigated (signing pending) |
| T2 | Download | Spoofing | A tampered installer on a mirror | Medium | High | README verification steps: `SHA256SUMS`, `gh attestation verify`, Authenticode | Mitigated |
| T3 | Settings file | Tampering | A crafted `settings.json` crashes the app or sets invalid values | Low | Low | Source-generated deserialization, `Sanitize` on load, defaults on failure | Mitigated |
| T4 | Logs | Information disclosure | Secrets or personal data written to logs | Low | Medium | Log calls take plain messages; secrets go only to Credential Manager (K22-SEC-71); review | Mitigated |
| T5 | Install folder | Elevation of privilege | DLL planting next to the exe | Low | High | Per-user install under `%LOCALAPPDATA%`, owned by the user; no loading from the working directory | Mitigated |
| T6 | Shell | Denial of service | An unhandled exception closes the app with unsaved work | Medium | Low | AppKit crash net logs and survives UI exceptions; one-dialog-at-a-time guard | Mitigated |
| T7 | Input | Denial of service | Very long input slows or breaks the UI | Low | Low | Length limits in Core services | Mitigated |
| T8 | Repository administration | Tampering | Setup weakens protections, trusts another check producer or targets another repository | Low | High | Offline preview; exact public target/admin checks; verify official Actions identity and every context binding in apply/readback; current-PR checks; refuse conflicting rules and broad release refs; preserve reviewed plan | Local regression tests pass; live bootstrap pending |
| T9 | PR review workflow | Elevation of privilege | Untrusted PR code runs with a checks write token | Low | High | Comment-triggered workflow executes trusted workflow SHA only; no PR checkout; human current-SHA acknowledgement required | Local tests pass; hosted enforcement pending |
| T10 | Package-manager distributions | Tampering | Manifest points at wrong bytes or an unsafe executable | Low | High | Stable-only generation; ZIP identity/layout checks; actual SHA256 and provenance verification; exclude helper shims; candidate review before feed publication | Local tests and real archive/schema checks pass; feed lifecycle pending |
| T11 | Installed update smoke | Tampering | A test overwrites a real app or deletes unrelated files | Low | Medium | Unique test package ID; fresh artifacts-only output; app identity check; literal arguments; offline URL allowlist; exact process path; root/manifest checks before uninstall | Local runtime/boundary tests pass; hosted CI pending |
## 7. Accepted risks
- EX-0002 tracks historical unsigned Windows release risk. The current application
release preflight rejects unsigned production publication. Verified deployment,
scoped CI authorization and final-artifact signature acceptance remain required;
completed publisher validation alone does not prove CI signing.
## 8. Review log
| Date | Version | Reviewer | Changes |
| --- | --- | --- | --- |
| 2026-10-09 | template baseline | maintainer | Initial model from the Windows desktop reference architecture |
| 2026-10-10 | repository setup | Codex (GPT-6) | Added administration and review workflow trust boundaries; live proof pending |
| 2026-10-10 | check producer readback | Codex (GPT-6) | Verify official Actions identity/bindings without mutating caller plans; clarify signing gate versus historical risk |
| 2026-10-10 | Codex (GPT-6) | Replaced template-only interfaces with actual document and browser boundaries; recorded separate unit/native/browser evidence |
@@ -22,7 +22,7 @@ public static class AppIdentity
{
Id = "markdownviewr-windows",
DisplayName = "MarkdownViewr",
Tagline = "Built on the KofTwentyTwo Windows app template",
Tagline = "A Windows Markdown reader",
Description = "A Windows Markdown viewer with live reload, themes and document navigation.",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/MarkdownViewr.Windows"),
Author = "James Maes",
@@ -38,7 +38,8 @@ public static class AppIdentity
Attributions =
[
new("KofTwentyTwo.AppKit", "MIT License"),
new("CommunityToolkit.Mvvm", "MIT License"),
new("Markdig", "BSD-2-Clause License"),
new("HtmlSanitizer / AngleSharp / AngleSharp.Css", "MIT License"),
new("Velopack", "MIT License"),
new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"),
new(".NET Runtime", "MIT License"),
+1 -16
View File
@@ -8,22 +8,7 @@ using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// The app's preferences: AppKit's shell settings (theme, splash, update checks) plus the
/// app's own. Add properties here, repair them in <see cref="Sanitize"/>, and they are
/// saved by <see cref="AppIdentity.SettingsStore"/>.
/// </summary>
/// <summary>Persistent AppKit shell preferences; document preferences will extend this type.</summary>
public sealed class AppSettings : ShellSettings
{
/// <summary>The name last greeted, offered again at the next start.</summary>
public string LastName { get; set; } = "";
/// <summary>Repairs the shell settings, then restores a missing name (JSON can supply null).</summary>
public override void Sanitize()
{
base.Sanitize();
LastName ??= "";
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Limits browser resource requests to passive images belonging to one opened document.</summary>
public sealed class DocumentResourcePolicy
{
private static readonly HashSet<string> s_imageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".avif", ".ico",
};
private static readonly HashSet<string> s_deviceNames = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³",
};
private readonly string _prefix;
/// <summary>Creates an isolated logical resource namespace for this document; no HTTP server is started.</summary>
public DocumentResourcePolicy()
{
_prefix = $"/documents/{Guid.NewGuid():N}/";
DocumentUri = new Uri("https://viewer.markdownviewr.invalid" + _prefix + "index.html");
}
/// <summary>The only top-level document URI the browser host may serve.</summary>
public Uri DocumentUri { get; }
/// <summary>Returns a decoded relative image path, or denies the request before filesystem access.</summary>
public string? GetImageRelativePath(Uri request)
{
ArgumentNullException.ThrowIfNull(request);
if(!request.IsAbsoluteUri || !string.Equals(request.Scheme, "https", StringComparison.Ordinal) || !string.Equals(request.Host, DocumentUri.Host, StringComparison.Ordinal) || request.Port != 443 ||
request.UserInfo.Length != 0 || !request.AbsolutePath.StartsWith(_prefix, StringComparison.Ordinal))
{
return null;
}
string relative = Uri.UnescapeDataString(request.AbsolutePath[_prefix.Length..]);
if(!s_imageExtensions.Contains(Path.GetExtension(relative)) || relative.Split('/').Any(segment => !IsSafeSegment(segment)))
{
return null;
}
return relative;
}
/// <summary>Rejects traversal, device names, separators, controls and Windows filename aliases.</summary>
private static bool IsSafeSegment(string segment)
{
return segment.Length > 0 && segment is not ("." or "..") && !segment.EndsWith(' ') && !segment.EndsWith('.') &&
!segment.Any(character => char.IsControl(character) || "<>:\\|?*".Contains(character, StringComparison.Ordinal)) &&
!s_deviceNames.Contains(segment.Split('.')[0]);
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Html.Dom;
using Ganss.Xss;
using Markdig;
using Markdig.Extensions.EmphasisExtras;
using Markdig.Extensions.Tables;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Renders GitHub Flavored Markdown into passive HTML for the isolated document surface.</summary>
public sealed class MarkdownRenderer
{
/// <summary>Bounds parser work before an opened document can consume unbounded memory.</summary>
public const int MaximumCharacters = 16 * 1024 * 1024;
private static readonly MarkdownPipeline s_pipeline = new MarkdownPipelineBuilder()
.UsePipeTables(new PipeTableOptions { UseGfmRules = true })
.UseTaskLists()
.UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
.UseAutoLinks()
.Build();
private readonly HtmlSanitizer _sanitizer;
/// <summary>Allows document formatting without scripts, CSS, forms or native host capabilities.</summary>
public MarkdownRenderer()
{
_sanitizer = new HtmlSanitizer();
_sanitizer.AllowedTags.Clear();
_sanitizer.AllowedTags.UnionWith(["a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "kbd", "li", "ol", "p", "pre", "s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]);
_sanitizer.AllowedAttributes.Clear();
_sanitizer.AllowedAttributes.UnionWith(["href", "src", "alt", "title", "align", "colspan", "rowspan", "width", "height", "type", "checked", "disabled"]);
_sanitizer.AllowedSchemes.Clear();
_sanitizer.AllowedSchemes.UnionWith(["http", "https", "mailto"]);
_sanitizer.AllowedCssProperties.Clear();
_sanitizer.AllowedAtRules.Clear();
_sanitizer.PostProcessNode += MakeInputsPassive;
}
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
public string Render(string markdown)
{
ArgumentNullException.ThrowIfNull(markdown);
if(markdown.Length > MaximumCharacters)
{
throw new ArgumentException("The document exceeds the 16 Mi-character rendering limit.", nameof(markdown));
}
return _sanitizer.Sanitize(Markdown.ToHtml(markdown, s_pipeline));
}
/// <summary>Task markers are display-only checkboxes; authored inputs cannot become active controls.</summary>
private static void MakeInputsPassive(object? sender, PostProcessNodeEventArgs args)
{
if(args.Node is IHtmlInputElement input)
{
input.Type = "checkbox";
input.IsDisabled = true;
}
}
}
@@ -1,60 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Greetings;
/// <summary>
/// The template's example feature: a greeting that depends on the time of day. It shows
/// the shape every feature takes: UI-free logic in Core, the clock injected through
/// <see cref="TimeProvider"/> so tests are deterministic (K22-TEST-20), and input
/// validated where it enters (K22-CODE-22). Replace it with the app's real features.
/// </summary>
public sealed class GreetingService
{
/// <summary>Longest name accepted, so a pasted document cannot become a greeting.</summary>
public const int MaxNameLength = 100;
private readonly TimeProvider _time;
/// <summary>Creates the service over <paramref name="time"/>; the system clock when null.</summary>
public GreetingService(TimeProvider? time = null)
{
_time = time ?? TimeProvider.System;
}
/// <summary>
/// "Good morning, Ada!" (or afternoon, or evening, by local time). A blank name greets
/// "there".
/// </summary>
/// <exception cref="ArgumentException">The name is longer than <see cref="MaxNameLength"/>.</exception>
public string Greet(string? name)
{
string trimmed = name?.Trim() ?? "";
if(trimmed.Length > MaxNameLength)
{
throw new ArgumentException($"A name can be at most {MaxNameLength} characters.", nameof(name));
}
return $"{PartOfDay()}, {(trimmed.Length == 0 ? "there" : trimmed)}!";
}
/// <summary>Morning before noon, afternoon before six, evening after.</summary>
private string PartOfDay()
{
int hour = _time.GetLocalNow().Hour;
return hour switch
{
< 12 => "Good morning",
< 18 => "Good afternoon",
_ => "Good evening",
};
}
}
@@ -1,77 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core.Greetings;
/// <summary>
/// Presentation state for the greeting screen. It lives in Core, not in the WinUI project,
/// so it is tested headlessly; the window binds to it with x:Bind and holds no logic.
/// </summary>
public sealed partial class GreetingViewModel : ObservableObject
{
private readonly GreetingService _greetings;
private readonly AppSettings _settings;
private readonly SettingsStore<AppSettings> _store;
private readonly IActivityLog _log;
/// <summary>Creates the view model, starting from the name saved last time.</summary>
public GreetingViewModel(GreetingService greetings, AppSettings settings, SettingsStore<AppSettings> store, IActivityLog log)
{
ArgumentNullException.ThrowIfNull(greetings);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(log);
_greetings = greetings;
_settings = settings;
_store = store;
_log = log;
Name = settings.LastName;
}
/// <summary>The name typed by the user.</summary>
[ObservableProperty]
public partial string Name { get; set; } = "";
/// <summary>The greeting shown, or the reason the name was rejected.</summary>
[ObservableProperty]
public partial string Message { get; set; } = "";
/// <summary>Greets <see cref="Name"/>, remembers it for next time, and logs the event.</summary>
[RelayCommand]
private void Greet()
{
try
{
Message = _greetings.Greet(Name);
}
catch(ArgumentException ex)
{
Message = ex.Message;
_log.Warning($"Greeting rejected: {ex.Message}");
return;
}
_settings.LastName = Name.Trim();
if(!_store.Save(_settings))
{
_log.Warning("Could not save the last greeted name.");
}
_log.Info("Greeted the user.");
}
}
@@ -11,7 +11,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="KofTwentyTwo.AppKit" />
</ItemGroup>
@@ -19,4 +18,9 @@
<InternalsVisibleTo Include="MarkdownViewr.Windows.Core.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" />
<PackageReference Include="HtmlSanitizer" />
</ItemGroup>
</Project>
@@ -2,11 +2,15 @@
"version": 2,
"dependencies": {
"net10.0": {
"CommunityToolkit.Mvvm": {
"HtmlSanitizer": {
"type": "Direct",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
"requested": "[9.2.1039, )",
"resolved": "9.2.1039",
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
"dependencies": {
"AngleSharp": "1.7.2",
"AngleSharp.Css": "1.0.2"
}
},
"KofTwentyTwo.AppKit": {
"type": "Direct",
@@ -14,6 +18,12 @@
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
},
"Markdig": {
"type": "Direct",
"requested": "[1.4.0, )",
"resolved": "1.4.0",
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
@@ -25,6 +35,19 @@
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"AngleSharp": {
"type": "Transitive",
"resolved": "1.7.2",
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
},
"AngleSharp.Css": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
"dependencies": {
"AngleSharp": "[1.5.0, 2.0.0)"
}
}
},
"net10.0/win-arm64": {},
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
namespace MarkdownViewr.Windows;
/// <summary>Hosts passive Markdown without network access, authored scripts or native capabilities.</summary>
internal sealed class DocumentBrowser
{
private const string SecurityHeaders = "Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nCache-Control: no-store";
private const string Styles = """
:root { color-scheme: light dark; font: 16px/1.65 system-ui, sans-serif; }
body { max-width: 900px; margin: 0 auto; padding: 32px; overflow-wrap: anywhere; }
h1,h2,h3,h4,h5,h6 { line-height: 1.25; margin-top: 1.5em; }
pre { padding: 16px; overflow: auto; background: light-dark(#f3f4f6,#22272e); border-radius: 6px; }
code,kbd { font-family: Consolas, monospace; }
table { border-collapse: collapse; } th,td { border: 1px solid #888; padding: 6px 12px; }
blockquote { margin-left: 0; padding-left: 20px; border-left: 4px solid #888; }
img { max-width: 100%; height: auto; } a { color: light-dark(#0969da,#58a6ff); }
""";
private readonly WebView2 _view;
private readonly Action<string> _status;
private CoreWebView2Environment? _environment;
private Core.Documents.DocumentResourcePolicy _policy = new();
private DocumentFiles? _files;
private byte[] _html = [];
/// <summary>Attaches a native status callback; the document itself cannot invoke host commands.</summary>
public DocumentBrowser(WebView2 view, Action<string> status)
{
_view = view;
_status = status;
}
/// <summary>Creates an isolated per-user browser profile and installs deny-by-default handlers before navigation.</summary>
public async Task InitializeAsync()
{
_environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(AppIdentity.Paths.DataRoot, "WebView2"), null);
await _view.EnsureCoreWebView2Async(_environment);
CoreWebView2 core = _view.CoreWebView2;
core.Settings.IsScriptEnabled = false;
core.Settings.AreHostObjectsAllowed = false;
core.Settings.IsWebMessageEnabled = false;
core.Settings.AreDefaultScriptDialogsEnabled = false;
core.Settings.AreDevToolsEnabled = false;
core.Settings.AreDefaultContextMenusEnabled = false;
core.PermissionRequested += (_, args) => args.State = CoreWebView2PermissionState.Deny;
core.DownloadStarting += (_, args) => args.Cancel = true;
core.NewWindowRequested += (_, args) => args.Handled = true;
core.FrameNavigationStarting += (_, args) => args.Cancel = true;
core.NavigationStarting += NavigationStarting;
core.NavigationCompleted += (_, args) =>
{
if(!args.IsSuccess)
{
_status("The document surface could not be displayed.");
}
};
core.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All, CoreWebView2WebResourceRequestSourceKinds.All);
core.WebResourceRequested += ResourceRequested;
Show(null, "<h1>MarkdownViewr</h1><p>Choose File → Open to read a Markdown document.</p>", "MarkdownViewr");
}
/// <summary>Replaces the document capability and navigates to an intercepted HTTPS response, with no HTTP server.</summary>
public void Show(DocumentFiles? files, string sanitizedHtml, string title)
{
_files = files;
_policy = new Core.Documents.DocumentResourcePolicy();
_html = Encoding.UTF8.GetBytes($"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{WebUtility.HtmlEncode(title)}</title><style>{Styles}</style></head><body>{sanitizedHtml}</body></html>""");
_view.CoreWebView2.Navigate(_policy.DocumentUri.AbsoluteUri);
}
/// <summary>Allows only the current document and fragment navigation; external links require an actual user gesture.</summary>
private async void NavigationStarting(CoreWebView2 sender, CoreWebView2NavigationStartingEventArgs args)
{
if(Uri.TryCreate(args.Uri, UriKind.Absolute, out Uri? uri) &&
string.Equals(uri.GetLeftPart(UriPartial.Path), _policy.DocumentUri.AbsoluteUri, StringComparison.Ordinal) &&
uri.Query.Length == 0)
{
return;
}
args.Cancel = true;
if(args.IsUserInitiated && uri is not null && uri.UserInfo.Length == 0 &&
!string.Equals(uri.Host, _policy.DocumentUri.Host, StringComparison.OrdinalIgnoreCase) && uri.Scheme is "https" or "http")
{
try
{
if(!await global::Windows.System.Launcher.LaunchUriAsync(uri))
{
_status("Windows could not open this link.");
}
}
catch(Exception exception) when(exception is ArgumentException or COMException)
{
_status("Windows could not open this link.");
}
}
else
{
_status("This link is outside the current document.");
}
}
/// <summary>Every browser resource receives either the document, a verified local image, or an empty denial response.</summary>
private void ResourceRequested(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args)
{
byte[] bytes = [];
string type = "text/plain";
int status = 403;
if(string.Equals(args.Request.Method, "GET", StringComparison.Ordinal) && Uri.TryCreate(args.Request.Uri, UriKind.Absolute, out Uri? uri))
{
if(uri == _policy.DocumentUri && args.ResourceContext == CoreWebView2WebResourceContext.Document)
{
bytes = _html;
type = "text/html; charset=utf-8";
status = 200;
}
else if(_files is not null && args.ResourceContext == CoreWebView2WebResourceContext.Image &&
_policy.GetImageRelativePath(uri) is string path)
{
try
{
bytes = _files.ReadImage(path);
type = Path.GetExtension(path).ToUpperInvariant() switch
{
".SVG" => "image/svg+xml",
".JPG" or ".JPEG" => "image/jpeg",
".PNG" => "image/png",
".GIF" => "image/gif",
".WEBP" => "image/webp",
".BMP" => "image/bmp",
".AVIF" => "image/avif",
_ => "image/x-icon",
};
status = 200;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException)
{
_status("One or more local images could not be loaded.");
}
}
}
var stream = new MemoryStream(bytes, writable: false);
args.Response = _environment!.CreateWebResourceResponse(stream.AsRandomAccessStream(), status, status == 200 ? "OK" : "Forbidden", $"Content-Type: {type}\r\n{SecurityHeaders}");
}
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace MarkdownViewr.Windows;
/// <summary>Reads a bounded document snapshot and serves images through verified open file handles.</summary>
internal sealed partial class DocumentFiles
{
private const int MaximumImageBytes = 16 * 1024 * 1024;
private readonly string _directory;
private readonly string _physicalRoot;
/// <summary>Retains the selected directory and the physical root of the document actually opened.</summary>
private DocumentFiles(string path, string physicalPath, string markdown)
{
_directory = Path.GetDirectoryName(path)!;
_physicalRoot = Path.GetDirectoryName(physicalPath)! + Path.DirectorySeparatorChar;
Markdown = markdown;
}
/// <summary>The bounded snapshot parsed into the document surface.</summary>
public string Markdown { get; }
/// <summary>Loads at most the parser limit plus one character, using the same handle for identity and contents.</summary>
public static DocumentFiles Open(string path)
{
string fullPath = Path.GetFullPath(path);
using SafeFileHandle handle = File.OpenHandle(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
string physicalPath = GetPhysicalPath(handle);
using var stream = new FileStream(handle, FileAccess.Read);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
char[] buffer = new char[Core.Documents.MarkdownRenderer.MaximumCharacters + 1];
int count = reader.ReadBlock(buffer, 0, buffer.Length);
if(count > Core.Documents.MarkdownRenderer.MaximumCharacters)
{
throw new IOException("This document exceeds the 16 Mi-character limit.");
}
return new DocumentFiles(fullPath, physicalPath, new string(buffer, 0, count));
}
/// <summary>Checks the final image handle against the physical document root before reading any image bytes.</summary>
public byte[] ReadImage(string relativePath)
{
using SafeFileHandle handle = File.OpenHandle(Path.Combine(_directory, relativePath), FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if(!GetPhysicalPath(handle).StartsWith(_physicalRoot, StringComparison.Ordinal))
{
throw new IOException("This image resolves outside the document folder.");
}
using var stream = new FileStream(handle, FileAccess.Read);
if(stream.Length > MaximumImageBytes)
{
throw new IOException("This image exceeds the 16 MiB limit.");
}
byte[] bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
return bytes;
}
/// <summary>Uses Windows normalization on the open handle; aliases and reparse points are resolved before access.</summary>
private static unsafe string GetPhysicalPath(SafeFileHandle handle)
{
const int Capacity = 32768;
char* buffer = stackalloc char[Capacity];
uint count = GetFinalPathNameByHandle(handle, buffer, Capacity, 0);
if(count == 0 || count >= Capacity)
{
throw new IOException("Could not verify the opened file's physical location.", Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()));
}
return new string(buffer, 0, checked((int)count));
}
/// <summary>Retrieves the normalized DOS path of an already opened file, including resolved reparse points.</summary>
[LibraryImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true)]
private static unsafe partial uint GetFinalPathNameByHandle(SafeFileHandle handle, char* path, uint capacity, uint flags);
}
+10 -13
View File
@@ -12,10 +12,18 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<MenuBar>
<MenuBarItem Title="File">
<MenuFlyoutItem x:Name="OpenMenuItem" Text="Open..." Click="Open_Click">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator Key="O" Modifiers="Control" />
</MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem>
<MenuFlyoutItem x:Name="ReloadMenuItem" Text="Reload" Click="Reload_Click" IsEnabled="False" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="View">
@@ -34,18 +42,7 @@
</MenuBarItem>
</MenuBar>
<!-- The example feature, bound to GreetingViewModel in MarkdownViewr.Windows.Core. Replace it with
the app's real content. -->
<StackPanel Grid.Row="1" Spacing="12" Padding="32" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock Text="MarkdownViewr" Style="{ThemeResource TitleTextBlockStyle}" />
<TextBlock TextWrapping="Wrap" Text="A Windows Markdown viewer with live reload, themes and document navigation." />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="NameBox" Width="260" PlaceholderText="Your name" Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button x:Name="GreetButton" Content="Greet" Command="{x:Bind ViewModel.GreetCommand}" />
</StackPanel>
<TextBlock x:Name="GreetingText" Text="{x:Bind ViewModel.Message, Mode=OneWay}" Style="{ThemeResource SubtitleTextBlockStyle}" />
<Button x:Name="ThrowButton" Content="Throw an unhandled exception" Click="Throw_Click" />
<TextBlock x:Name="StatusText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
<WebView2 x:Name="DocumentView" Grid.Row="1" Loaded="DocumentView_Loaded" AutomationProperties.Name="Markdown document" />
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="16,8" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</Grid>
</Window>
+103 -18
View File
@@ -7,6 +7,7 @@ using KofTwentyTwo.AppKit.Settings;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
namespace MarkdownViewr.Windows;
@@ -19,16 +20,20 @@ public sealed partial class MainWindow : Window
{
private readonly AppSettings _settings = AppIdentity.SettingsStore.Load();
private readonly UpdateCoordinator _updates;
private readonly DocumentBrowser _browser;
private Task? _browserInitialization;
private string? _documentPath;
private bool _loaded;
/// <summary>Sizes and brands the window, creates the view model, shows the splash, and schedules the quiet startup update check.</summary>
/// <summary>Sizes and brands the window, creates the document host, shows the splash, and schedules the quiet startup update check.</summary>
public MainWindow()
{
ViewModel = new GreetingViewModel(new GreetingService(), _settings, AppIdentity.SettingsStore, App.Log);
InitializeComponent();
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message);
Title = AppIdentity.Info.DisplayName;
this.ResizeForDpi(900, 600);
this.ResizeForDpi(1100, 760);
this.SetMinimumSize(640, 420);
this.SetAppIcon(AppIdentity.Info);
@@ -43,7 +48,11 @@ public sealed partial class MainWindow : Window
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
SplashOverlay.ShowOver(RootGrid, AppIdentity.Info, _settings);
Closed += (_, _) => LogWindow.CloseCurrent(); // a log-only process would linger otherwise
Closed += (_, _) =>
{
DocumentView.Close();
LogWindow.CloseCurrent();
};
if(_settings.CheckForUpdatesOnStartup)
{
@@ -54,11 +63,6 @@ public sealed partial class MainWindow : Window
/// <summary>The greeting screen's state; x:Bind targets it.</summary>
public GreetingViewModel ViewModel { get; }
/// <summary>
/// Waits for the splash to clear (dialogs need the window's XamlRoot), then runs the
/// quiet startup update check, which only speaks up when an update exists.
@@ -71,6 +75,96 @@ public sealed partial class MainWindow : Window
/// <summary>Initializes the browser once and opens a document supplied as a literal process argument.</summary>
private async void DocumentView_Loaded(object sender, RoutedEventArgs e)
{
if(_loaded)
{
return;
}
_loaded = true;
_browserInitialization ??= _browser.InitializeAsync();
try
{
await _browserInitialization;
StatusText.Text = "Choose File → Open to read a Markdown document.";
string[] arguments = Environment.GetCommandLineArgs();
if(arguments.Length == 2)
{
await OpenDocumentAsync(arguments[1]);
}
}
catch(Exception exception) when(exception is IOException or System.Runtime.InteropServices.COMException)
{
StatusText.Text = "The document surface could not start. Check that the Microsoft Edge WebView2 Runtime is installed.";
App.Log.Error("The document surface could not start.", exception);
}
}
/// <summary>Shows the Windows picker in this window; cancellation leaves the current document intact.</summary>
private async void Open_Click(object sender, RoutedEventArgs e)
{
var picker = new FileOpenPicker();
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
picker.FileTypeFilter.Add(".md");
picker.FileTypeFilter.Add(".markdown");
picker.FileTypeFilter.Add(".txt");
global::Windows.Storage.StorageFile? file = await picker.PickSingleFileAsync();
if(file is not null)
{
await OpenDocumentAsync(file.Path);
}
}
/// <summary>Reopens the selected file explicitly; automatic save watching will build on this path.</summary>
private async void Reload_Click(object sender, RoutedEventArgs e)
{
if(_documentPath is not null)
{
await OpenDocumentAsync(_documentPath);
}
}
/// <summary>Reads and parses outside the UI thread; a failed open preserves the previous document.</summary>
private async Task OpenDocumentAsync(string path)
{
OpenMenuItem.IsEnabled = false;
ReloadMenuItem.IsEnabled = false;
try
{
_browserInitialization ??= _browser.InitializeAsync();
await _browserInitialization;
(DocumentFiles files, string html) = await Task.Run(() =>
{
var document = DocumentFiles.Open(path);
return (document, new Core.Documents.MarkdownRenderer().Render(document.Markdown));
});
_browser.Show(files, html, Path.GetFileName(path));
_documentPath = Path.GetFullPath(path);
Title = Path.GetFileName(path) + " — " + AppIdentity.Info.DisplayName;
StatusText.Text = "Opened " + Path.GetFileName(path);
App.Log.Info("Opened a Markdown document.");
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException or System.Runtime.InteropServices.COMException)
{
StatusText.Text = "Could not open this document: " + exception.Message;
App.Log.Warning("Could not open a Markdown document.");
}
finally
{
OpenMenuItem.IsEnabled = true;
ReloadMenuItem.IsEnabled = _documentPath is not null;
}
}
/// <summary>File, Exit.</summary>
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
@@ -119,15 +213,6 @@ public sealed partial class MainWindow : Window
/// <summary>Throws on purpose: proves the crash net logs the exception and the app keeps running. Remove it from a real app.</summary>
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
throw new InvalidOperationException("Deliberately unhandled exception from the template.");
}
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
private void Save()
{
@@ -12,6 +12,8 @@
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<!-- LibraryImport generates the file-handle path verification stub. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<!-- Velopack must run before any XAML starts, so the app supplies its own Main (Program.cs). -->
@@ -23,6 +25,7 @@
reach the build and publish output, since the unpackaged app has no MSIX step. -->
<Content Include="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\THIRD-PARTY-NOTICES.md" Link="THIRD-PARTY-NOTICES.md" CopyToOutputDirectory="PreserveNewest" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
@@ -41,7 +44,6 @@
<!-- The app's own namespaces are global usings: their sort position against other
usings depends on the app's name, which the template replaces. -->
<Using Include="MarkdownViewr.Windows.Core" />
<Using Include="MarkdownViewr.Windows.Core.Greetings" />
</ItemGroup>
<!-- Unpackaged WinUI publishes omit the app PRI/XBF items (WindowsAppSDK#6720).
+30 -6
View File
@@ -54,6 +54,19 @@
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
}
},
"AngleSharp": {
"type": "Transitive",
"resolved": "1.7.2",
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
},
"AngleSharp.Css": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
"dependencies": {
"AngleSharp": "[1.5.0, 2.0.0)"
}
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
@@ -175,21 +188,32 @@
"markdownviewr.windows.core": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"KofTwentyTwo.AppKit": "[0.1.0, )"
"HtmlSanitizer": "[9.2.1039, )",
"KofTwentyTwo.AppKit": "[0.1.0, )",
"Markdig": "[1.4.0, )"
}
},
"CommunityToolkit.Mvvm": {
"HtmlSanitizer": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
"requested": "[9.2.1039, )",
"resolved": "9.2.1039",
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
"dependencies": {
"AngleSharp": "1.7.2",
"AngleSharp.Css": "1.0.2"
}
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
},
"Markdig": {
"type": "CentralTransitive",
"requested": "[1.4.0, )",
"resolved": "1.4.0",
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
}
},
"net10.0-windows10.0.19041/win-arm64": {
@@ -49,25 +49,24 @@ public class AppIdentityTests
/// <summary>Tests of the app's settings.</summary>
public class AppSettingsTests
{
/// <summary>Sanitize: a null name from JSON becomes empty, and the shell settings are repaired too.</summary>
/// <summary>Sanitize: invalid shell preferences fall back to their supported defaults.</summary>
[Fact]
public void Sanitize_RepairsNullsAndShellSettings()
public void Sanitize_RepairsShellSettings()
{
var settings = new AppSettings { LastName = null!, Theme = "Purple" };
var settings = new AppSettings { Theme = "Purple" };
settings.Sanitize();
Assert.Equal("", settings.LastName);
Assert.Equal("System", settings.Theme);
}
/// <summary>Round trip: the name survives a save and a load.</summary>
/// <summary>Round trip: the shell theme survives a save and a load.</summary>
[Fact]
public void SaveAndLoad_KeepTheName()
public void SaveAndLoad_KeepsTheTheme()
{
using var temp = new TempDirectory();
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
Assert.True(store.Save(new AppSettings { LastName = "Ada" }));
Assert.Equal("Ada", store.Load().LastName);
Assert.True(store.Save(new AppSettings { Theme = "Dark" }));
Assert.Equal("Dark", store.Load().Theme);
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Verifies document image isolation and Windows path rejection before any native file access.</summary>
public sealed class DocumentResourcePolicyTests
{
/// <summary>Each opened document has a distinct, inert HTTPS namespace.</summary>
[Fact]
public void Constructor_DifferentDocuments_UsesDistinctNamespaces()
{
var first = new DocumentResourcePolicy();
var second = new DocumentResourcePolicy();
Assert.NotEqual(first.DocumentUri, second.DocumentUri);
Assert.Equal("https", first.DocumentUri.Scheme);
Assert.Equal("viewer.markdownviewr.invalid", first.DocumentUri.Host);
Assert.EndsWith("/index.html", first.DocumentUri.AbsolutePath, StringComparison.Ordinal);
}
/// <summary>Supported local images retain their relative spelling and are decoded only once.</summary>
[Theory]
[InlineData("images/photo.PNG", "images/photo.PNG")]
[InlineData("images/hello%20world.jpg", "images/hello world.jpg")]
[InlineData("images/%252e%252e.png", "images/%2e%2e.png")]
[InlineData("diagram.svg#view", "diagram.svg")]
[InlineData("image.webp?cache=1", "image.webp")]
public void GetImageRelativePath_AllowedImage_ReturnsOnceDecodedPath(string path, string expected)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(expected, policy.GetImageRelativePath(new Uri(policy.DocumentUri, path)));
}
/// <summary>Other origins, ports, credentials and document namespaces have no resource access.</summary>
[Theory]
[InlineData("http://viewer.markdownviewr.invalid/image.png")]
[InlineData("https://example.com/image.png")]
[InlineData("https://viewer.markdownviewr.invalid:444/image.png")]
[InlineData("https://user@viewer.markdownviewr.invalid/image.png")]
[InlineData("file:///C:/private.png")]
[InlineData("data:image/png;base64,AAAA")]
public void GetImageRelativePath_ForeignRequest_DeniesAccess(string url)
{
Assert.Null(new DocumentResourcePolicy().GetImageRelativePath(new Uri(url)));
}
/// <summary>Requests cannot cross into another opened document.</summary>
[Fact]
public void GetImageRelativePath_OtherDocument_DeniesAccess()
{
var policy = new DocumentResourcePolicy();
var other = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(new Uri(other.DocumentUri, "image.png")));
}
/// <summary>Non-images, traversal, device names, encoded separators and filename aliases are refused.</summary>
[Theory]
[InlineData("index.html")]
[InlineData("script.js")]
[InlineData("../private.png")]
[InlineData("%2e%2e/private.png")]
[InlineData("images/%2e%2e%2f%2e%2e%2fprivate.png")]
[InlineData("images/%5cprivate.png")]
[InlineData("images/C%3a/private.png")]
[InlineData("images/%00private.png")]
[InlineData("images/%0aprivate.png")]
[InlineData("images/a%3f.png")]
[InlineData("images/a%2a.png")]
[InlineData("images/a%3c.png")]
[InlineData("images/a%7c.png")]
[InlineData("images//private.png")]
[InlineData("images/folder%20/private.png")]
[InlineData("images/folder./private.png")]
[InlineData("CON.png")]
[InlineData("images/LPT1.png")]
[InlineData("images/COM%C2%B9.png")]
public void GetImageRelativePath_UnsafePath_DeniesAccess(string path)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, path)));
}
/// <summary>Relative request URIs and absent requests have explicit refusal behavior.</summary>
[Fact]
public void GetImageRelativePath_RelativeAndNull_RejectsInvalidRequests()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(new Uri("image.png", UriKind.Relative)));
Assert.Throws<ArgumentNullException>(() => policy.GetImageRelativePath(null!));
}
}
@@ -3,59 +3,9 @@
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>A clock fixed at one local time, in a UTC zone so local time equals the given time.</summary>
internal sealed class FixedTimeProvider(int hour) : TimeProvider
{
/// <summary>Today at the given hour.</summary>
public override DateTimeOffset GetUtcNow() => new(2026, 3, 15, hour, 30, 0, TimeSpan.Zero);
/// <summary>UTC, so GetLocalNow returns GetUtcNow unchanged.</summary>
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
/// <summary>An activity log that records entries in memory.</summary>
internal sealed class RecordingLog : IActivityLog
{
/// <summary>Entries as "LEVEL message".</summary>
public List<string> Entries { get; } = [];
/// <inheritdoc/>
public string LogDirectory => "";
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message) => Entries.Add("INFO " + message);
/// <inheritdoc/>
public void Warning(string message) => Entries.Add("WARN " + message);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
}
/// <summary>A per-test temporary directory, deleted on dispose.</summary>
internal sealed class TempDirectory : IDisposable
{
@@ -1,148 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Tests of the example greeting feature's logic.</summary>
public class GreetingServiceTests
{
/// <summary>Greet: the part of the day follows the local hour, including the boundaries.</summary>
[Theory]
[InlineData(0, "Good morning, Ada!")]
[InlineData(11, "Good morning, Ada!")]
[InlineData(12, "Good afternoon, Ada!")]
[InlineData(17, "Good afternoon, Ada!")]
[InlineData(18, "Good evening, Ada!")]
[InlineData(23, "Good evening, Ada!")]
public void Greet_FollowsTheTimeOfDay(int hour, string expected)
{
Assert.Equal(expected, new GreetingService(new FixedTimeProvider(hour)).Greet("Ada"));
}
/// <summary>Greet: a blank or missing name greets "there"; surrounding spaces are trimmed.</summary>
[Theory]
[InlineData(null, "Good morning, there!")]
[InlineData(" ", "Good morning, there!")]
[InlineData(" Ada ", "Good morning, Ada!")]
public void Greet_HandlesBlankAndPaddedNames(string? name, string expected)
{
Assert.Equal(expected, new GreetingService(new FixedTimeProvider(9)).Greet(name));
}
/// <summary>Greet: a name longer than the limit is rejected.</summary>
[Fact]
public void Greet_RejectsOverlongNames()
{
ArgumentException ex = Assert.Throws<ArgumentException>(() => new GreetingService().Greet(new string('x', GreetingService.MaxNameLength + 1)));
Assert.Equal("name", ex.ParamName);
}
}
/// <summary>Tests of the greeting screen's view model.</summary>
public sealed class GreetingViewModelTests : IDisposable
{
private readonly TempDirectory _temp = new();
private readonly RecordingLog _log = new();
/// <summary>Deletes the temporary settings folder.</summary>
public void Dispose() => _temp.Dispose();
/// <summary>A view model over a settings store in the temporary folder.</summary>
private GreetingViewModel Create(AppSettings settings, string? settingsPath = null)
{
var store = new SettingsStore<AppSettings>(settingsPath ?? _temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
return new GreetingViewModel(new GreetingService(new FixedTimeProvider(20)), settings, store, _log);
}
/// <summary>Construction: starts from the name saved last time.</summary>
[Fact]
public void StartsWithTheSavedName()
{
Assert.Equal("Grace", Create(new AppSettings { LastName = "Grace" }).Name);
}
/// <summary>Greet: shows the greeting, saves the trimmed name, and logs it.</summary>
[Fact]
public void Greet_ShowsSavesAndLogs()
{
var settings = new AppSettings();
GreetingViewModel model = Create(settings);
var changed = new List<string?>();
model.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
model.Name = " Ada ";
model.GreetCommand.Execute(null);
Assert.Equal("Good evening, Ada!", model.Message);
Assert.Equal("Ada", settings.LastName);
Assert.Contains(changed, name => string.Equals(name, nameof(GreetingViewModel.Message), StringComparison.Ordinal));
Assert.Equal("Ada", new SettingsStore<AppSettings>(_temp.File("settings.json"), AppSettingsContext.Default.AppSettings).Load().LastName);
Assert.Equal(["INFO Greeted the user."], _log.Entries);
}
/// <summary>Greet: an overlong name shows the reason, warns, and saves nothing.</summary>
[Fact]
public void Greet_RejectsAndWarns()
{
var settings = new AppSettings { LastName = "Kept" };
GreetingViewModel model = Create(settings);
model.Name = new string('x', GreetingService.MaxNameLength + 1);
model.GreetCommand.Execute(null);
Assert.StartsWith("A name can be at most", model.Message, StringComparison.Ordinal);
Assert.Equal("Kept", settings.LastName);
Assert.StartsWith("WARN Greeting rejected", Assert.Single(_log.Entries), StringComparison.Ordinal);
}
/// <summary>Greet: a failed settings save is logged, and the greeting still shows.</summary>
[Fact]
public void Greet_LogsAFailedSave()
{
string notADirectory = _temp.File("file");
File.WriteAllText(notADirectory, "");
GreetingViewModel model = Create(new AppSettings(), Path.Combine(notADirectory, "settings.json"));
model.Name = "Ada";
model.GreetCommand.Execute(null);
Assert.Equal("Good evening, Ada!", model.Message);
Assert.Equal(["WARN Could not save the last greeted name.", "INFO Greeted the user."], _log.Entries);
}
/// <summary>Construction: every dependency is required.</summary>
[Fact]
public void Constructor_ValidatesArguments()
{
var store = new SettingsStore<AppSettings>(_temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
var service = new GreetingService();
var settings = new AppSettings();
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(null!, settings, store, _log));
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, null!, store, _log));
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, settings, null!, _log));
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, settings, store, null!));
}
}
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Verifies useful document output and hostile-markup boundaries before browser integration.</summary>
public sealed class MarkdownRendererTests
{
/// <summary>GFM content retains headings, tables, task markers, strikethrough and fenced code.</summary>
[Fact]
public void Render_GfmDocument_PreservesRequiredFormatting()
{
const string Markdown = "# Heading\n\n| Name | Value |\n| --- | --- |\n| A | B |\n\n- [x] Done\n- [ ] Pending\n\n~~Old~~\n\n```csharp\nvar x = 1;\n```\n\nhttps://example.com\n";
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(Markdown));
Assert.Equal("Heading", document.QuerySelector("h1")!.TextContent);
Assert.Equal("B", document.QuerySelectorAll("td")[1].TextContent);
Assert.Equal("Old", document.QuerySelector("del")!.TextContent);
Assert.Contains("var x = 1;", document.QuerySelector("pre code")!.TextContent, StringComparison.Ordinal);
Assert.Equal("https://example.com", document.QuerySelector("a")!.GetAttribute("href"));
IElement[] inputs = document.QuerySelectorAll("input").ToArray();
Assert.Equal(2, inputs.Length);
Assert.All(inputs, input => Assert.True(Assert.IsAssignableFrom<IHtmlInputElement>(input).IsDisabled));
Assert.True(Assert.IsAssignableFrom<IHtmlInputElement>(inputs[0]).IsChecked);
Assert.False(Assert.IsAssignableFrom<IHtmlInputElement>(inputs[1]).IsChecked);
}
/// <summary>Useful authored HTML and relative resources survive without active styling or identity attributes.</summary>
[Fact]
public void Render_SafeHtml_PreservesContentAndRelativeResources()
{
const string Markdown = "<details><summary>More</summary><p id='native-host' class='shell' style='color:red'>Content</p></details>\n\n![Image](images/example.png)\n\n[Section](#heading)";
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(Markdown));
Assert.Equal("More", document.QuerySelector("summary")!.TextContent);
IElement paragraph = document.QuerySelector("details p")!;
Assert.Equal("Content", paragraph.TextContent);
Assert.Empty(paragraph.Attributes);
Assert.Equal("images/example.png", document.QuerySelector("img")!.GetAttribute("src"));
Assert.Equal("#heading", document.QuerySelector("a")!.GetAttribute("href"));
}
/// <summary>Malformed and active markup cannot introduce executable nodes or host-facing attributes.</summary>
[Theory]
[InlineData("<script>window.chrome.webview.postMessage('run');</script><p onclick='evil()'>Safe</p>")]
[InlineData("<svg><script>alert(1)</script><a xlink:href='javascript:evil()'>Bad</a></svg>")]
[InlineData("<iframe srcdoc='<script>evil()</script>'></iframe><object data='file:///C:/private'></object>")]
[InlineData("<form action='https://example.com'><button>Send</button><input type='password' oninput='evil()'></form>")]
[InlineData("<meta http-equiv='refresh' content='0;url=file:///C:/private'><base href='file:///C:/private'>")]
[InlineData("<style>@import url(https://example.com);</style><img src='image.png' onerror='evil()' srcset='https://example.com 2x'>")]
public void Render_ActiveMarkup_RemovesExecutableElementsAndAttributes(string markdown)
{
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(markdown));
Assert.Empty(document.QuerySelectorAll("script,svg,iframe,object,embed,form,button,style,base,meta[http-equiv]"));
foreach(IElement element in document.Body!.QuerySelectorAll("*"))
{
Assert.DoesNotContain(element.Attributes, attribute => attribute.Name.StartsWith("on", StringComparison.OrdinalIgnoreCase));
Assert.False(element.HasAttribute("srcdoc"));
Assert.False(element.HasAttribute("srcset"));
}
}
/// <summary>Unsafe URI schemes are rejected after HTML entity/control-character decoding.</summary>
[Theory]
[InlineData("<a href='javascript:alert(1)'>Click</a>")]
[InlineData("<a href='JaVaScRiPt:alert(1)'>Click</a>")]
[InlineData("<a href='java&#x09;script:alert(1)'>Click</a>")]
[InlineData("<a href='vbscript:evil()'>Click</a>")]
[InlineData("<a href='file:///C:/private'>Click</a>")]
[InlineData("<img src='data:image/svg+xml,%3Csvg%20onload=evil()%3E'>")]
public void Render_UnsafeUrls_RemovesResourceCapabilities(string markdown)
{
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(markdown));
Assert.All(document.QuerySelectorAll("a,img"), element =>
{
Assert.False(element.HasAttribute("href"));
Assert.False(element.HasAttribute("src"));
});
}
/// <summary>Document-authored input types are normalized to passive checkbox markers.</summary>
[Fact]
public void Render_AuthoredInput_ForcesDisabledCheckbox()
{
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render("<input type='text' checked>"));
IHtmlInputElement input = Assert.IsAssignableFrom<IHtmlInputElement>(document.QuerySelector("input"));
Assert.Equal("checkbox", input.Type);
Assert.True(input.IsDisabled);
Assert.True(input.IsChecked);
}
/// <summary>Missing documents fail explicitly; empty documents render as empty content.</summary>
[Fact]
public void Render_NullAndEmpty_UsesExplicitContract()
{
var renderer = new MarkdownRenderer();
Assert.Throws<ArgumentNullException>(() => renderer.Render(null!));
Assert.Equal("", renderer.Render(""));
}
/// <summary>The size guard rejects over-limit input before parsing it.</summary>
[Fact]
public void Render_OverLimit_RejectsBeforeParsing()
{
Assert.Throws<ArgumentException>(() => new MarkdownRenderer().Render(new string('a', MarkdownRenderer.MaximumCharacters + 1)));
}
/// <summary>A shared renderer handles concurrent documents without cross-document state.</summary>
[Fact]
public async Task Render_ConcurrentDocuments_DoesNotLeakState()
{
var renderer = new MarkdownRenderer();
Task<string>[] tasks = Enumerable.Range(0, 24).Select(index => Task.Run(() => renderer.Render($"# Document {index}"))).ToArray();
string[] output = await Task.WhenAll(tasks);
for(int index = 0; index < output.Length; index++)
{
Assert.Equal($"<h1>Document {index}</h1>\n", output[index]);
}
}
}
@@ -22,7 +22,6 @@
<ItemGroup>
<!-- The app's own namespaces are global usings: their sort position against other
usings depends on the app's name, which the template replaces. -->
<Using Include="MarkdownViewr.Windows.Core.Greetings" />
</ItemGroup>
</Project>
@@ -41,6 +41,19 @@
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"AngleSharp": {
"type": "Transitive",
"resolved": "1.7.2",
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
},
"AngleSharp.Css": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
"dependencies": {
"AngleSharp": "[1.5.0, 2.0.0)"
}
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
@@ -102,21 +115,32 @@
"markdownviewr.windows.core": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"KofTwentyTwo.AppKit": "[0.1.0, )"
"HtmlSanitizer": "[9.2.1039, )",
"KofTwentyTwo.AppKit": "[0.1.0, )",
"Markdig": "[1.4.0, )"
}
},
"CommunityToolkit.Mvvm": {
"HtmlSanitizer": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
"requested": "[9.2.1039, )",
"resolved": "9.2.1039",
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
"dependencies": {
"AngleSharp": "1.7.2",
"AngleSharp.Css": "1.0.2"
}
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
},
"Markdig": {
"type": "CentralTransitive",
"requested": "[1.4.0, )",
"resolved": "1.4.0",
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
}
}
}
@@ -32,7 +32,7 @@ public sealed class AppSession : IDisposable
/// <summary>Launches the app against a fresh temporary data folder and waits for its main window.</summary>
public AppSession(bool showSplash = false)
public AppSession(bool showSplash = false, string? document = null)
{
ExePath = ResolveExePath();
DataDirectory = Path.Combine(Path.GetTempPath(), AppIdentity.Info.Id + "-uitests", Guid.NewGuid().ToString("N"));
@@ -43,6 +43,12 @@ public sealed class AppSession : IDisposable
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
startInfo.Environment[AppIdentity.Info.DataDirectoryVariable] = DataDirectory;
if(document is not null)
{
string path = Path.Combine(DataDirectory, "document.md");
File.WriteAllText(path, document);
startInfo.ArgumentList.Add(path);
}
_dpi = new DpiAwarenessScope();
try
+23 -49
View File
@@ -10,7 +10,7 @@ using Xunit.Abstractions;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>
/// End-to-end tests of the real app: the AppKit shell pieces and the example feature.
/// End-to-end tests of the real app: the AppKit shell pieces and the document surface.
/// UI tests share one desktop, so they never run in parallel.
/// </summary>
[Collection("Desktop")]
@@ -50,30 +50,6 @@ public class AppTests
/// <summary>Crash net: an unhandled exception is logged and the app keeps running.</summary>
[Fact]
public void UnhandledException_IsLoggedAndTheAppSurvives()
{
using var session = new AppSession();
try
{
session.MouseClickElement("ThrowButton");
_output.WriteLine(session.LastMouseInput);
session.WaitFor(
() => session.ReadLogs().Contains("Deliberately unhandled exception from the template", StringComparison.Ordinal) ? session.MainWindow : null,
"the crash net's log entry");
}
catch(TimeoutException exception)
{
AutomationElement? status = session.FindInApp(cf => cf.ByAutomationId("StatusText"));
throw new TimeoutException($"{exception.Message} {session.LastMouseInput} Observed status: '{status?.Properties.Name.ValueOrDefault}'. App logs: {session.ReadLogs()}", exception);
}
Assert.False(session.App.HasExited);
Assert.Contains("Unhandled UI exception", session.ReadLogs(), StringComparison.Ordinal);
}
/// <summary>Activity log: opens a window showing the log.</summary>
[Fact]
public void ActivityLog_OpensAWindowShowingTheLog()
@@ -124,33 +100,31 @@ public class AppTests
/// <summary>Example feature: greeting a name shows the greeting and saves the name.</summary>
/// <summary>The launched document appears in the actual browser accessibility tree.</summary>
[Fact]
public void Greet_ShowsTheGreetingAndSavesTheName()
public void OpenDocument_GfmDocument_ShowsRenderedContent()
{
using var session = new AppSession();
session.EnterText("NameBox", "Ada");
session.ClickElement("GreetButton");
string? observedGreeting = null;
try
{
session.WaitFor(
() =>
{
AutomationElement? text = session.FindInApp(cf => cf.ByAutomationId("GreetingText"));
observedGreeting = text?.Properties.Name.ValueOrDefault;
return observedGreeting?.EndsWith(", Ada!", StringComparison.Ordinal) == true ? text : null;
},
"the greeting");
}
catch(TimeoutException exception)
{
throw new TimeoutException($"{exception.Message} Observed greeting: '{observedGreeting}'. App logs: {session.ReadLogs()}", exception);
}
session.WaitFor(
() => session.ReadSettings().Contains("\"LastName\": \"Ada\"", StringComparison.Ordinal) ? session.MainWindow : null,
"settings.json to record the name");
using var session = new AppSession(document: "# Reader acceptance\n\n| Column | Value |\n| --- | --- |\n| One | Two |\n\n- [x] Finished\n\n<script>document.body.textContent='UNSAFE EXECUTION'</script>");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Reader acceptance")), "the rendered document heading", TimeSpan.FromSeconds(30));
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Two")), "the rendered table cell");
Assert.Null(session.FindInApp(cf => cf.ByName("UNSAFE EXECUTION")));
Assert.Contains("Opened a Markdown document.", session.ReadLogs(), StringComparison.Ordinal);
}
/// <summary>Reload uses the actual source file again instead of preserving an obsolete rendered snapshot.</summary>
[Fact]
public void Reload_ChangedDocument_ShowsNewContent()
{
using var session = new AppSession(document: "# Before reload");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Before reload")), "the initial heading", TimeSpan.FromSeconds(30));
File.WriteAllText(Path.Combine(session.DataDirectory, "document.md"), "# After reload");
session.InvokeMenuItem("File", "ReloadMenuItem");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("After reload")), "the reloaded heading");
Assert.Null(session.FindInApp(cf => cf.ByName("Before reload")));
}
}
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>Exercises bounded snapshots and physical resource containment without launching the GUI.</summary>
public sealed class DocumentFilesTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "markdownviewr-files-" + Guid.NewGuid().ToString("N"));
/// <summary>Creates a private document folder and a sibling outside the resource boundary.</summary>
public DocumentFilesTests()
{
Directory.CreateDirectory(Path.Combine(_root, "document"));
Directory.CreateDirectory(Path.Combine(_root, "outside"));
}
/// <summary>File loading honors the BOM and reads the selected document snapshot.</summary>
[Fact]
public void Open_UnicodeDocument_ReadsTheSnapshot()
{
string path = Path.Combine(_root, "document", "read me.md");
File.WriteAllText(path, "# Café", Encoding.Unicode);
var files = DocumentFiles.Open(path);
Assert.Equal("# Café", files.Markdown);
}
/// <summary>Ordinary relative images inside the selected folder retain their exact bytes.</summary>
[Fact]
public void ReadImage_InsideDocumentFolder_ReturnsExactBytes()
{
DocumentFiles files = CreateDocument();
byte[] expected = [1, 2, 3, 4];
File.WriteAllBytes(Path.Combine(_root, "document", "image.png"), expected);
Assert.Equal(expected, files.ReadImage("image.png"));
}
/// <summary>Physical containment also rejects traversal if a caller bypasses the earlier URI policy.</summary>
[Fact]
public void ReadImage_OutsideDocumentFolder_ThrowsBeforeReading()
{
DocumentFiles files = CreateDocument();
File.WriteAllBytes(Path.Combine(_root, "outside", "secret.png"), [1, 2, 3]);
Assert.Throws<IOException>(() => files.ReadImage(Path.Combine("..", "outside", "secret.png")));
}
/// <summary>An allowed-looking image path cannot use a directory junction to escape the document root.</summary>
[Fact]
public void ReadImage_JunctionOutsideDocumentFolder_ThrowsBeforeReading()
{
DocumentFiles files = CreateDocument();
string link = Path.Combine(_root, "document", "images");
File.WriteAllBytes(Path.Combine(_root, "outside", "secret.png"), [1, 2, 3]);
var start = new ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true };
start.ArgumentList.Add("/c");
start.ArgumentList.Add("mklink");
start.ArgumentList.Add("/J");
start.ArgumentList.Add(link);
start.ArgumentList.Add(Path.Combine(_root, "outside"));
using Process process = Process.Start(start)!;
Assert.True(process.WaitForExit(10000), "Junction creation did not finish.");
Assert.Equal(0, process.ExitCode);
try
{
Assert.Throws<IOException>(() => files.ReadImage(Path.Combine("images", "secret.png")));
}
finally
{
Directory.Delete(link);
}
}
/// <summary>A document above the parser's bound is rejected before parsing.</summary>
[Fact]
public void Open_OverCharacterLimit_Throws()
{
string path = Path.Combine(_root, "document", "large.md");
File.WriteAllText(path, new string('x', Core.Documents.MarkdownRenderer.MaximumCharacters + 1));
Assert.Throws<IOException>(() => DocumentFiles.Open(path));
}
/// <summary>Oversized images do not allocate an unbounded browser response.</summary>
[Fact]
public void ReadImage_OverByteLimit_Throws()
{
DocumentFiles files = CreateDocument();
string path = Path.Combine(_root, "document", "large.png");
using(var stream = new FileStream(path, FileMode.CreateNew))
{
stream.SetLength(16 * 1024 * 1024 + 1);
}
Assert.Throws<IOException>(() => files.ReadImage("large.png"));
}
/// <summary>Creates the source file and returns its established physical resource capability.</summary>
private DocumentFiles CreateDocument()
{
string path = Path.Combine(_root, "document", "read.md");
File.WriteAllText(path, "# Document");
return DocumentFiles.Open(path);
}
/// <summary>Deletes only the unique temporary folder owned by this test instance.</summary>
public void Dispose() => Directory.Delete(_root, recursive: true);
}
@@ -6,6 +6,7 @@
point K22_UITEST_EXE at the exe to test. -->
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
@@ -29,4 +30,9 @@
<Using Include="MarkdownViewr.Windows.Core" />
</ItemGroup>
<ItemGroup>
<!-- Exercise the actual native file adapter without loading WinUI or reserving the desktop. -->
<Compile Include="..\..\src\MarkdownViewr.Windows\DocumentFiles.cs" Link="WindowsAdapters\DocumentFiles.cs" />
</ItemGroup>
</Project>
@@ -45,6 +45,19 @@
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"AngleSharp": {
"type": "Transitive",
"resolved": "1.7.2",
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
},
"AngleSharp.Css": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
"dependencies": {
"AngleSharp": "[1.5.0, 2.0.0)"
}
},
"FlaUI.Core": {
"type": "Transitive",
"resolved": "5.0.0",
@@ -188,21 +201,32 @@
"markdownviewr.windows.core": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"KofTwentyTwo.AppKit": "[0.1.0, )"
"HtmlSanitizer": "[9.2.1039, )",
"KofTwentyTwo.AppKit": "[0.1.0, )",
"Markdig": "[1.4.0, )"
}
},
"CommunityToolkit.Mvvm": {
"HtmlSanitizer": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
"requested": "[9.2.1039, )",
"resolved": "9.2.1039",
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
"dependencies": {
"AngleSharp": "1.7.2",
"AngleSharp.Css": "1.0.2"
}
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
},
"Markdig": {
"type": "CentralTransitive",
"requested": "[1.4.0, )",
"resolved": "1.4.0",
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
}
}
}