diff --git a/AGENTS.md b/AGENTS.md index bfd56fe..92994b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b84ed5..7f78696 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,7 +21,8 @@ - + + diff --git a/README.md b/README.md index c1fa47f..1a754bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..a51aae0 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,109 @@ +# Third-party rendering libraries + +These notices accompany the rendering libraries shipped with MarkdownViewr. + +## Markdig 1.4.0 + +Source: + +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: + +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: + +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: + +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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 2487de2..e359d15 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 5373226..fd32aa9 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 51e0558..a8c6429 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/docs/adr/0002-isolated-markdown-rendering.md b/docs/adr/0002-isolated-markdown-rendering.md new file mode 100644 index 0000000..682749b --- /dev/null +++ b/docs/adr/0002-isolated-markdown-rendering.md @@ -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) diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 8844df8..e2d66ca 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -1,92 +1,60 @@ # Threat model: MarkdownViewr - +- **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
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 | diff --git a/src/MarkdownViewr.Windows.Core/AppIdentity.cs b/src/MarkdownViewr.Windows.Core/AppIdentity.cs index d90f3ae..fc6ec41 100644 --- a/src/MarkdownViewr.Windows.Core/AppIdentity.cs +++ b/src/MarkdownViewr.Windows.Core/AppIdentity.cs @@ -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"), diff --git a/src/MarkdownViewr.Windows.Core/AppSettings.cs b/src/MarkdownViewr.Windows.Core/AppSettings.cs index b620b4f..09db20f 100644 --- a/src/MarkdownViewr.Windows.Core/AppSettings.cs +++ b/src/MarkdownViewr.Windows.Core/AppSettings.cs @@ -8,22 +8,7 @@ using KofTwentyTwo.AppKit.Settings; namespace MarkdownViewr.Windows.Core; -/// -/// The app's preferences: AppKit's shell settings (theme, splash, update checks) plus the -/// app's own. Add properties here, repair them in , and they are -/// saved by . -/// +/// Persistent AppKit shell preferences; document preferences will extend this type. public sealed class AppSettings : ShellSettings { - /// The name last greeted, offered again at the next start. - public string LastName { get; set; } = ""; - - - - /// Repairs the shell settings, then restores a missing name (JSON can supply null). - public override void Sanitize() - { - base.Sanitize(); - LastName ??= ""; - } } diff --git a/src/MarkdownViewr.Windows.Core/Documents/DocumentResourcePolicy.cs b/src/MarkdownViewr.Windows.Core/Documents/DocumentResourcePolicy.cs new file mode 100644 index 0000000..5fbfef6 --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Documents/DocumentResourcePolicy.cs @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + + +namespace MarkdownViewr.Windows.Core.Documents; + +/// Limits browser resource requests to passive images belonging to one opened document. +public sealed class DocumentResourcePolicy +{ + private static readonly HashSet s_imageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".avif", ".ico", + }; + + private static readonly HashSet 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; + + + + /// Creates an isolated logical resource namespace for this document; no HTTP server is started. + public DocumentResourcePolicy() + { + _prefix = $"/documents/{Guid.NewGuid():N}/"; + DocumentUri = new Uri("https://viewer.markdownviewr.invalid" + _prefix + "index.html"); + } + + + + /// The only top-level document URI the browser host may serve. + public Uri DocumentUri { get; } + + + + /// Returns a decoded relative image path, or denies the request before filesystem access. + 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; + } + + + + /// Rejects traversal, device names, separators, controls and Windows filename aliases. + 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]); + } +} diff --git a/src/MarkdownViewr.Windows.Core/Documents/MarkdownRenderer.cs b/src/MarkdownViewr.Windows.Core/Documents/MarkdownRenderer.cs new file mode 100644 index 0000000..bae4c6c --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Documents/MarkdownRenderer.cs @@ -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; + +/// Renders GitHub Flavored Markdown into passive HTML for the isolated document surface. +public sealed class MarkdownRenderer +{ + /// Bounds parser work before an opened document can consume unbounded memory. + 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; + + + + /// Allows document formatting without scripts, CSS, forms or native host capabilities. + 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; + } + + + + /// Preserves useful markup while sanitizing every parser result, including authored raw HTML. + 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)); + } + + + + /// Task markers are display-only checkboxes; authored inputs cannot become active controls. + private static void MakeInputsPassive(object? sender, PostProcessNodeEventArgs args) + { + if(args.Node is IHtmlInputElement input) + { + input.Type = "checkbox"; + input.IsDisabled = true; + } + } +} diff --git a/src/MarkdownViewr.Windows.Core/Greetings/GreetingService.cs b/src/MarkdownViewr.Windows.Core/Greetings/GreetingService.cs deleted file mode 100644 index 2d9eb5f..0000000 --- a/src/MarkdownViewr.Windows.Core/Greetings/GreetingService.cs +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2026 James Maes - * SPDX-License-Identifier: MIT - */ - -namespace MarkdownViewr.Windows.Core.Greetings; - -/// -/// 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 -/// so tests are deterministic (K22-TEST-20), and input -/// validated where it enters (K22-CODE-22). Replace it with the app's real features. -/// -public sealed class GreetingService -{ - /// Longest name accepted, so a pasted document cannot become a greeting. - public const int MaxNameLength = 100; - - private readonly TimeProvider _time; - - - - /// Creates the service over ; the system clock when null. - public GreetingService(TimeProvider? time = null) - { - _time = time ?? TimeProvider.System; - } - - - - /// - /// "Good morning, Ada!" (or afternoon, or evening, by local time). A blank name greets - /// "there". - /// - /// The name is longer than . - 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)}!"; - } - - - - /// Morning before noon, afternoon before six, evening after. - private string PartOfDay() - { - int hour = _time.GetLocalNow().Hour; - return hour switch - { - < 12 => "Good morning", - < 18 => "Good afternoon", - _ => "Good evening", - }; - } -} diff --git a/src/MarkdownViewr.Windows.Core/Greetings/GreetingViewModel.cs b/src/MarkdownViewr.Windows.Core/Greetings/GreetingViewModel.cs deleted file mode 100644 index e667101..0000000 --- a/src/MarkdownViewr.Windows.Core/Greetings/GreetingViewModel.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public sealed partial class GreetingViewModel : ObservableObject -{ - private readonly GreetingService _greetings; - private readonly AppSettings _settings; - private readonly SettingsStore _store; - private readonly IActivityLog _log; - - - - /// Creates the view model, starting from the name saved last time. - public GreetingViewModel(GreetingService greetings, AppSettings settings, SettingsStore 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; - } - - - - /// The name typed by the user. - [ObservableProperty] - public partial string Name { get; set; } = ""; - - - - /// The greeting shown, or the reason the name was rejected. - [ObservableProperty] - public partial string Message { get; set; } = ""; - - - - /// Greets , remembers it for next time, and logs the event. - [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."); - } -} diff --git a/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj b/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj index 89f49de..029540a 100644 --- a/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj +++ b/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj @@ -11,7 +11,6 @@ - @@ -19,4 +18,9 @@
+ + + + + diff --git a/src/MarkdownViewr.Windows.Core/packages.lock.json b/src/MarkdownViewr.Windows.Core/packages.lock.json index 94f96e2..81362b8 100644 --- a/src/MarkdownViewr.Windows.Core/packages.lock.json +++ b/src/MarkdownViewr.Windows.Core/packages.lock.json @@ -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": {}, diff --git a/src/MarkdownViewr.Windows/DocumentBrowser.cs b/src/MarkdownViewr.Windows/DocumentBrowser.cs new file mode 100644 index 0000000..48cac60 --- /dev/null +++ b/src/MarkdownViewr.Windows/DocumentBrowser.cs @@ -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; + +/// Hosts passive Markdown without network access, authored scripts or native capabilities. +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 _status; + private CoreWebView2Environment? _environment; + private Core.Documents.DocumentResourcePolicy _policy = new(); + private DocumentFiles? _files; + private byte[] _html = []; + + + + /// Attaches a native status callback; the document itself cannot invoke host commands. + public DocumentBrowser(WebView2 view, Action status) + { + _view = view; + _status = status; + } + + + + /// Creates an isolated per-user browser profile and installs deny-by-default handlers before navigation. + 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, "

MarkdownViewr

Choose File → Open to read a Markdown document.

", "MarkdownViewr"); + } + + + + /// Replaces the document capability and navigates to an intercepted HTTPS response, with no HTTP server. + public void Show(DocumentFiles? files, string sanitizedHtml, string title) + { + _files = files; + _policy = new Core.Documents.DocumentResourcePolicy(); + _html = Encoding.UTF8.GetBytes($"""{WebUtility.HtmlEncode(title)}{sanitizedHtml}"""); + _view.CoreWebView2.Navigate(_policy.DocumentUri.AbsoluteUri); + } + + + + /// Allows only the current document and fragment navigation; external links require an actual user gesture. + 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."); + } + } + + + + /// Every browser resource receives either the document, a verified local image, or an empty denial response. + 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}"); + } +} diff --git a/src/MarkdownViewr.Windows/DocumentFiles.cs b/src/MarkdownViewr.Windows/DocumentFiles.cs new file mode 100644 index 0000000..dc2e636 --- /dev/null +++ b/src/MarkdownViewr.Windows/DocumentFiles.cs @@ -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; + +/// Reads a bounded document snapshot and serves images through verified open file handles. +internal sealed partial class DocumentFiles +{ + private const int MaximumImageBytes = 16 * 1024 * 1024; + private readonly string _directory; + private readonly string _physicalRoot; + + + + /// Retains the selected directory and the physical root of the document actually opened. + private DocumentFiles(string path, string physicalPath, string markdown) + { + _directory = Path.GetDirectoryName(path)!; + _physicalRoot = Path.GetDirectoryName(physicalPath)! + Path.DirectorySeparatorChar; + Markdown = markdown; + } + + + + /// The bounded snapshot parsed into the document surface. + public string Markdown { get; } + + + + /// Loads at most the parser limit plus one character, using the same handle for identity and contents. + 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)); + } + + + + /// Checks the final image handle against the physical document root before reading any image bytes. + 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; + } + + + + /// Uses Windows normalization on the open handle; aliases and reparse points are resolved before access. + 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)); + } + + + + /// Retrieves the normalized DOS path of an already opened file, including resolved reparse points. + [LibraryImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true)] + private static unsafe partial uint GetFinalPathNameByHandle(SafeFileHandle handle, char* path, uint capacity, uint flags); +} diff --git a/src/MarkdownViewr.Windows/MainWindow.xaml b/src/MarkdownViewr.Windows/MainWindow.xaml index cb58490..9879827 100644 --- a/src/MarkdownViewr.Windows/MainWindow.xaml +++ b/src/MarkdownViewr.Windows/MainWindow.xaml @@ -12,10 +12,18 @@ + + + + + + + + @@ -34,18 +42,7 @@ - - - - - - -