Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c680d7c02
|
No files matched your search
@@ -44,7 +44,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: >-
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build
|
||||
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
|
||||
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~Outline_NestedCollapse'
|
||||
--logger 'trx;LogFileName=reader-regressions.trx'
|
||||
--results-directory artifacts/reader-regressions
|
||||
- name: Retain native regression evidence
|
||||
|
||||
@@ -41,21 +41,12 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet build tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 -warnaserror --no-restore
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Restore and build activation adapter targets
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet restore tests/MarkdownViewr.Windows.UiTests -p:Platform=x64 --locked-mode
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet build tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 -warnaserror --no-restore
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Verify seeded parser security properties
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Output "Fuzz seeds22023/99173, five targets, $env:K22_FUZZ_ITERATIONS iterations per seed/target."
|
||||
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=fuzz.trx' --results-directory artifacts/fuzz
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=activation-fuzz.trx' --results-directory artifacts/fuzz
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Retain synthetic campaign failures and results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
|
||||
@@ -30,7 +30,7 @@ and isolated boundary evidence in ADR-0005. Explorer and installed lifecycle
|
||||
acceptance remain required; literal process arguments alone are not association
|
||||
proof.
|
||||
|
||||
Store at most20 successful absolute paths in the private settings file,
|
||||
Store at most20 successful absolute paths in the private AppKit settings file,
|
||||
deduplicated with Windows path comparison. Update only after a parsed snapshot
|
||||
commits. Clear history removes entries without deleting files or closing readers.
|
||||
Keep paths out of activity logs/telemetry. Each preference save merges only fields
|
||||
|
||||
@@ -33,10 +33,22 @@ crossing processes; the public SDK launch envelope cannot carry sender CWD.
|
||||
Deliver requests through a current-user-only .NET named pipe. Accept only a
|
||||
source-generated bounded schema: at most128 absolute paths, a1MiB frame and an
|
||||
optional fixed invalid-path notification. Reject unknown fields; no command or
|
||||
script operation exists. Connection/read/acknowledgement waits are bounded to
|
||||
10seconds, with at most16 requests queued before XAML attaches its dispatcher.
|
||||
Malformed/disconnected peers cannot stop later valid requests. App shutdown
|
||||
cancels and disposes the listener. No dependencies or generic RPC layer are added.
|
||||
script operation exists. A sender waits at most2 seconds for the reader's pipe
|
||||
and each side at most10 seconds for one exchange, with at most16 requests held
|
||||
before XAML attaches its dispatcher. Malformed/disconnected peers cannot stop
|
||||
later valid requests. App shutdown cancels and disposes the listener, releasing
|
||||
the pipe name before the instance key. No dependencies or generic RPC layer are added.
|
||||
|
||||
The request, the schema and the inbox live in the core library
|
||||
(`Core/Activation`), where the unit tests and the coverage gate reach them.
|
||||
|
||||
Forwarding is best effort; opening the document is not. A launch whose request
|
||||
the running reader did not take (no listener, a refusal, or a deadline) opens
|
||||
its documents in its own process. A reader that cannot create its inbox, because
|
||||
the pipe name is already held by a reader that is still closing or by another
|
||||
program, runs without one; later launches then open their own documents. An
|
||||
argument that cannot name a document is dropped and reported in the reader
|
||||
window while the usable arguments still open.
|
||||
|
||||
The foreground launcher permits the primary to focus an existing document.
|
||||
Dispatch all window creation on the native UI thread; duplicate paths include
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# ADR-0006: Settings file protocol
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-11-10
|
||||
|
||||
## Context
|
||||
|
||||
One `settings.json` per edition is shared by every reader window and by every
|
||||
process that uses the same data folder: an installed copy and a portable copy, or
|
||||
two builds started from different folders, are separate processes over one file.
|
||||
|
||||
AppKit's `SettingsStore` returns defaults whenever the file cannot be read and writes
|
||||
a whole snapshot through one fixed temporary name with no lock. With real files and
|
||||
real processes this lost data in two ways: a load during a sharing violation returned
|
||||
defaults that the next save wrote back, and two processes saving in turn erased each
|
||||
other's changes.
|
||||
|
||||
The files beside `settings.json` are now a contract between application versions that
|
||||
may run at the same time, so the protocol is recorded here.
|
||||
|
||||
## Decision
|
||||
|
||||
`AppSettingsStore` in the core library owns the file. AppKit still defines the shell
|
||||
preferences (`ShellSettings`); only persistence moved.
|
||||
|
||||
**Change, not snapshot.** `Update` takes a function from the stored settings to the
|
||||
new settings. It runs under the lock against what is on disk at that moment, so a
|
||||
caller cannot write back a stale copy of a value it did not change.
|
||||
|
||||
**Lock.** `settings.json.lock` is opened with no sharing for the duration of an
|
||||
update. It is never deleted: deleting a lock file leaves a window in which a second
|
||||
opener is refused with access denied. The operating system releases the handle if
|
||||
the process dies. A lock that stays held for every attempt refuses the update.
|
||||
|
||||
**Read for an update.** The file is opened for reading while denying writers, and
|
||||
that handle is held until the rename. A program rewriting the file in place therefore
|
||||
reads as busy, not as invalid content, and none can start between the read and the
|
||||
rename. An empty file is waited on for the same attempts, because a program that
|
||||
clears a file before rewriting it leaves it empty for a moment.
|
||||
|
||||
**Read for display.** The same open is tried first. If another handle has write
|
||||
access, the file is read beside it instead of waiting, since such a handle is usually
|
||||
idle. Content read that way which does not parse is retried and, if it never parses,
|
||||
reported as unreadable rather than corrupt: it cannot be judged while it can still be
|
||||
written. An empty file reads as defaults at once.
|
||||
|
||||
**What a read can find.**
|
||||
|
||||
| Found | Used for display | On update |
|
||||
| --- | --- | --- |
|
||||
| No file | Defaults | The file is created |
|
||||
| Empty file | Defaults | Replaced after the wait; nothing kept aside |
|
||||
| Valid settings | The stored values | Replaced |
|
||||
| Some invalid properties | Valid properties kept, invalid ones as defaults | Replaced; a copy of the original is kept aside |
|
||||
| Not settings at all | Defaults | Replaced; a copy of the original is kept aside, unless it is over 16 MiB |
|
||||
| Unreadable or being written | Defaults | Refused; nothing is written |
|
||||
|
||||
A trailing comma, a comment and a quoted number are valid: they are what a hand edit
|
||||
commonly leaves behind. Text the JSON reader cannot decode, such as an unpaired
|
||||
surrogate escape, makes its property invalid. Repair works per top-level property:
|
||||
one bad entry in a list costs that list. A file over 16 MiB is not loaded.
|
||||
|
||||
**Write.** The new content is written to `settings.json.<32 hex digits>.tmp` and
|
||||
flushed to disk. That file is then renamed over `settings.json` in one step with the
|
||||
Windows rename that replaces an open file (`FileRenameInfoEx` with POSIX semantics).
|
||||
At no point is there no `settings.json`: a reader sees the old file or the new one,
|
||||
and a process killed at any moment leaves one of them. Handles that were open on the
|
||||
old file and allow deletion go on reading it. `File.Replace` is not used because it
|
||||
is two renames with the file missing between them, and `File.Move` with overwrite is
|
||||
not used because it refuses while any handle is open on the destination.
|
||||
|
||||
The rename is given the path in the form Windows takes as written, so a data folder
|
||||
whose path is longer than 260 characters, or whose name is not well-formed UTF-16,
|
||||
behaves like any other.
|
||||
|
||||
On a volume that does not implement that rename, the store closes its own read
|
||||
handle and falls back to `File.Move` with overwrite. If the file did not exist when
|
||||
it was read, the temporary file is renamed into place without overwriting. A failed
|
||||
update leaves the stored file as it was and removes its temporary file.
|
||||
|
||||
**Kept-aside copies.** Before invalid content is replaced it is copied to a temporary
|
||||
file. Once the save has succeeded the copy is renamed to `settings.json.corrupt`, or
|
||||
to the first free of `settings.json.corrupt-2` to `settings.json.corrupt-9`; the last
|
||||
name is overwritten once all exist. A save that fails removes its temporary copy and
|
||||
leaves every earlier copy as it was, and the invalid file is still in place. The copy
|
||||
is a courtesy for recovering a hand edit, with these limits:
|
||||
|
||||
- a file over 16 MiB is replaced without a copy, since it was never this
|
||||
application's settings and copying it would stall the window;
|
||||
- a copy that cannot be written, for example on a full disk, or cannot be given its
|
||||
name, is removed and reported, and the save goes ahead, because refusing would
|
||||
block every later change too;
|
||||
- a process killed between the save and the rename of the copy loses that copy.
|
||||
|
||||
Nothing reads the copies back. They contain whatever the file contained, including
|
||||
recent-document paths, so clearing recent files deletes them.
|
||||
|
||||
**Abandoned temporary files.** A process killed between its write and its rename
|
||||
leaves a temporary file, and possibly a temporary copy. The next update, holding the
|
||||
lock, removes files that match this store's own name pattern and no others.
|
||||
|
||||
**Waiting.** Only a sharing violation is waited out: at most 20 attempts, 50 ms
|
||||
apart, for the lock, the read and the rename. Access denied is treated as permanent
|
||||
and reported at once, so a read-only profile does not stall a window. The fallback
|
||||
move is the exception: it answers access denied while any handle is open on the
|
||||
file, a reader's included, so there access denied is waited out as well, unless the
|
||||
file carries the read-only attribute.
|
||||
|
||||
**Reporting.** Faults are reported by kind with the exception type and HRESULT. No
|
||||
path and no operating-system message text reaches the log.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An update is a synchronous call on the window thread. It normally takes a few
|
||||
milliseconds and at most about one second per contended step.
|
||||
- The JSON written is byte-compatible with earlier releases, so a downgrade reads it.
|
||||
- The rename needs Windows 10 version 1607 or later and a file system that supports
|
||||
it; NTFS does. Other volumes use the fallback, which is not safe against a kill
|
||||
between closing the file and the move completing.
|
||||
- An earlier release running against the same data folder still has its own defect:
|
||||
it takes no lock, and when a read fails it falls back to defaults and may save
|
||||
them. This store cannot prevent that. It also makes that release's saves fail more
|
||||
often while both run, because the earlier release cannot replace a file this store
|
||||
has open. Run one version per data folder.
|
||||
- The store keeps no in-memory copy. Windows hold their own view of the settings and
|
||||
merge only the fields they changed (ADR-0004).
|
||||
|
||||
## Options considered
|
||||
|
||||
- **A named mutex instead of a lock file.** Rejected: a session-local name does not
|
||||
cover two sessions of one user on the same profile, and a global name can be
|
||||
created first by another account on the machine.
|
||||
- **Renaming the invalid file away instead of copying it.** Rejected: between that
|
||||
rename and the new file arriving there is no settings file, and a kill there loses
|
||||
the profile.
|
||||
- **Fixing AppKit's store instead.** Still worthwhile for other applications, but the
|
||||
read-modify-write API is a different shape, and this application needed the fix
|
||||
without waiting for a package release.
|
||||
@@ -11,7 +11,7 @@
|
||||
"reason": "Untrusted Markdown/HTML/frontmatter, source ranges/folds, resource URLs, imported theme JSON and activation JSON are parser boundaries. Seeded mutation targets verify unsafe acceptance as well as exceptions.",
|
||||
"targets": [
|
||||
"tests/MarkdownViewr.Windows.Core.Tests/SecurityFuzzTests.cs",
|
||||
"tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs"
|
||||
"tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs"
|
||||
],
|
||||
"command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz",
|
||||
"workflow": ".github/workflows/fuzz.yml",
|
||||
|
||||
@@ -30,8 +30,7 @@ Run from the repository root with the pinned .NET 10 SDK on Windows:
|
||||
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
|
||||
$env:K22_FUZZ_ITERATIONS = '500'
|
||||
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --filter Category=Fuzz
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --filter Category=Fuzz
|
||||
# Weekly and before every MINOR/MAJOR release, repeat both commands with 10000.
|
||||
# Weekly and before every MINOR/MAJOR release, repeat the command with 10000.
|
||||
$env:K22_FUZZ_ITERATIONS = '10000'
|
||||
```
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| 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 |
|
||||
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. Settings changes are read-modify-write under a lock file beside `settings.json` and are renamed into place in one step, so the file is never absent; a file that is unreadable or being written by another program is never overwritten, a copy of invalid content is kept aside under a bounded set of names and removed when recent files are cleared, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
|
||||
| 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 |
|
||||
|
||||
@@ -43,7 +43,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Threat | Mitigation and evidence |
|
||||
| --- | --- |
|
||||
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser malicious-script and reader acceptance passed on 77dd04d |
|
||||
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access. Reserved device names and forbidden characters are one written-out rule (`WindowsFileNames`), independent of the running Windows version: every device name in any letter case, with any extension, and with spaces before the extension |
|
||||
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
|
||||
| Junction/symlink escape or check/reopen race | Check normalized physical path on opened image handle; read same handle; real junction rejection test passes |
|
||||
| Remote image tracking / resource exfiltration | No-referrer header, no document networking; actual listening-server browser denial passed on 77dd04d |
|
||||
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
|
||||
@@ -324,6 +324,13 @@ relative/control-containing/null/overlong paths and more than128 paths; frames
|
||||
are bounded to1MiB, connection/read waits to10seconds and pre-XAML queuing to16
|
||||
requests. Normal document size/resource/sanitizer boundaries still apply.
|
||||
|
||||
The pipe name is derived from the executable and data folders, so another
|
||||
program running as any user on the machine can create that name first. That
|
||||
cannot expose a document or redirect one: both ends require a pipe owned by the
|
||||
current user. It could only deny forwarding, so a reader that cannot create its
|
||||
inbox starts without one, and a launch whose request is not taken opens its own
|
||||
documents.
|
||||
|
||||
Malformed/disconnected requests are rejected and the listener continues; owned
|
||||
shutdown cancels the pipe. Fixed notifications and failure kind/HRESULT logging
|
||||
do not expose paths or payloads. UI-dispatched duplicate activation preserves
|
||||
|
||||
@@ -73,8 +73,6 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
|
||||
- **Open in editor is disabled:** hover for the reason, then configure an installed editor in Settings.
|
||||
- **The viewer is not a default option:** use Setup rather than a portable copy, then reopen Windows Default Apps.
|
||||
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
|
||||
- **A setting could not be saved:** another program is using `settings.json`, or the file or folder is read-only. Nothing is overwritten while the file cannot be read; try the change again.
|
||||
- **Settings were edited by hand:** a trailing comma, a comment or a quoted number is accepted; the app rewrites the file in its own format on the next save, so comments are not kept. A syntax error such as a missing comma or quote makes the whole file unreadable: every setting starts from its default, and the next save keeps a copy of your file as described below. A setting whose value is not valid is ignored and the other settings are kept. When either happens, the next save keeps a copy of the file as it was, named `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`). Those copies contain everything the file contained; **File → Recent files → Clear recent files** deletes them.
|
||||
|
||||
For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
/// <summary>
|
||||
/// The running reader's inbox for documents a later launch asks it to open. One named pipe,
|
||||
/// open only to the current Windows user, carries one length-prefixed <see cref="ActivationRequest"/>
|
||||
/// per connection and answers with one byte: accepted or not.
|
||||
/// </summary>
|
||||
public sealed class ActivationInbox : IDisposable
|
||||
{
|
||||
/// <summary>Requests held while the reader is still starting; a further one is refused so the sender can report it.</summary>
|
||||
public const int MaximumPendingRequests = 16;
|
||||
|
||||
/// <summary>How long a sender waits for the reader's pipe to exist and be free.</summary>
|
||||
public static readonly TimeSpan ConnectDeadline = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>How long either side waits for the other to finish one request.</summary>
|
||||
public static readonly TimeSpan ExchangeDeadline = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<ActivationRequest> _pending = new();
|
||||
private readonly CancellationTokenSource _closed = new();
|
||||
private readonly NamedPipeServerStream _pipe;
|
||||
private readonly Action<Exception> _report;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Task _listener;
|
||||
private Func<ActivationRequest, bool>? _dispatch;
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates the pipe and starts listening. Throws when the pipe name is already taken, by a
|
||||
/// reader that has not finished closing or by another program; the caller then runs without
|
||||
/// an inbox. Deadlines run on <paramref name="time"/>, the system clock unless a test supplies its own.
|
||||
/// </summary>
|
||||
public ActivationInbox(string key, Action<Exception> report, TimeProvider? time = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
_report = report;
|
||||
_time = time ?? TimeProvider.System;
|
||||
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
_listener = ListenAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Completes when the listener has stopped after <see cref="Dispose"/>.</summary>
|
||||
public Task Completion => _listener;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Connects the inbox to the reader once its windows can open documents, delivering first the
|
||||
/// requests that arrived while it was starting. The dispatcher returns false when it cannot
|
||||
/// take a request.
|
||||
/// </summary>
|
||||
public void Attach(Func<ActivationRequest, bool> dispatch)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatch);
|
||||
lock(_gate)
|
||||
{
|
||||
_dispatch = dispatch;
|
||||
while(_pending.TryDequeue(out ActivationRequest? request))
|
||||
{
|
||||
if(!dispatch(request))
|
||||
{
|
||||
_report(new IOException("The reader could not accept activation."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one request to the running reader and returns whether it accepted it.</summary>
|
||||
public static Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
|
||||
=> SendAsync(key, request, TimeProvider.System, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one request with its deadlines on the given clock. Throws when no reader answers in time or the request is too large to send.</summary>
|
||||
internal static async Task<bool> SendAsync(string key, ActivationRequest request, TimeProvider time, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
request.Validate();
|
||||
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
|
||||
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new ArgumentException("The activation payload is too large.", nameof(request));
|
||||
}
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
using(var deadline = new CancellationTokenSource(ConnectDeadline, time))
|
||||
using(var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token))
|
||||
{
|
||||
await client.ConnectAsync(connecting.Token);
|
||||
}
|
||||
using var exchangeDeadline = new CancellationTokenSource(ExchangeDeadline, time);
|
||||
using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exchangeDeadline.Token);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
|
||||
await client.WriteAsync(length, exchanging.Token);
|
||||
await client.WriteAsync(payload, exchanging.Token);
|
||||
await client.FlushAsync(exchanging.Token);
|
||||
byte[] response = new byte[1];
|
||||
await client.ReadExactlyAsync(response, exchanging.Token);
|
||||
return response[0] == 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Serves one connection at a time until disposed; a peer that disconnects, stalls or sends an invalid request is reported and does not stop the next one.</summary>
|
||||
private async Task ListenAsync()
|
||||
{
|
||||
while(!_closed.IsCancellationRequested)
|
||||
{
|
||||
bool connected = false;
|
||||
try
|
||||
{
|
||||
await _pipe.WaitForConnectionAsync(_closed.Token);
|
||||
connected = true;
|
||||
using var deadline = new CancellationTokenSource(ExchangeDeadline, _time);
|
||||
using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token, deadline.Token);
|
||||
bool accepted = Accept(await ReadRequestAsync(exchanging.Token));
|
||||
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, exchanging.Token);
|
||||
await _pipe.FlushAsync(exchanging.Token);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
if(!_closed.IsCancellationRequested)
|
||||
{
|
||||
_report(exception);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Under the lock Dispose takes, so the pipe cannot be disposed between the check and the call.
|
||||
lock(_gate)
|
||||
{
|
||||
if(connected && !_closed.IsCancellationRequested)
|
||||
{
|
||||
// A failed read clears IsConnected, but the connection still has to be released before the next peer.
|
||||
_pipe.Disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads one bounded, length-prefixed request and checks it against the schema and the path rules.</summary>
|
||||
private async Task<ActivationRequest> ReadRequestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] length = new byte[4];
|
||||
await _pipe.ReadExactlyAsync(length, cancellationToken);
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
|
||||
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new InvalidDataException("Invalid activation length.");
|
||||
}
|
||||
byte[] payload = new byte[size];
|
||||
await _pipe.ReadExactlyAsync(payload, cancellationToken);
|
||||
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
|
||||
request.Validate();
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hands the request to the reader, or holds it until the reader attaches; false when neither is possible.</summary>
|
||||
private bool Accept(ActivationRequest request)
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
if(_dispatch is not null)
|
||||
{
|
||||
return _dispatch(request);
|
||||
}
|
||||
if(_pending.Count >= MaximumPendingRequests)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pending.Enqueue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stops listening and releases the pipe name.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if(Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock(_gate)
|
||||
{
|
||||
_closed.Cancel();
|
||||
_pipe.Dispose();
|
||||
}
|
||||
_closed.Dispose();
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -6,10 +6,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
/// <summary>Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol.</summary>
|
||||
/// <summary>The fixed schema of an activation message; a field it does not define is an error, so the message cannot grow into a command protocol.</summary>
|
||||
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
|
||||
[JsonSerializable(typeof(ActivationRequest))]
|
||||
internal sealed partial class ActivationJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
/// <summary>
|
||||
/// The documents one launch asks to open: bounded, literal, absolute paths. This is the whole
|
||||
/// message a second launch sends to the running reader; it carries no commands.
|
||||
/// </summary>
|
||||
public sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
|
||||
{
|
||||
/// <summary>The most documents one launch may open.</summary>
|
||||
public const int MaximumPaths = 128;
|
||||
|
||||
/// <summary>The largest serialized request the running reader accepts.</summary>
|
||||
public const int MaximumPayloadBytes = 1024 * 1024;
|
||||
|
||||
private const int MaximumPathCharacters = 32767;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves each argument against the launching process's folder, so a relative path keeps
|
||||
/// its meaning when the running reader has a different working folder. An argument that is
|
||||
/// not a usable path is dropped and flagged; the usable ones still open.
|
||||
/// </summary>
|
||||
public static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
|
||||
if(candidates.Length > MaximumPaths)
|
||||
{
|
||||
throw new ArgumentException("Too many document paths.", nameof(paths));
|
||||
}
|
||||
string?[] resolved = candidates.Select(path => Resolve(path, workingDirectory)).ToArray();
|
||||
return new ActivationRequest(resolved.OfType<string>().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), resolved.Contains(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rejects a request that did not come from <see cref="Create"/>: a relative, empty, overlong or control-bearing path, or too many of them.</summary>
|
||||
public void Validate()
|
||||
{
|
||||
if(Paths is null || Paths.Length > MaximumPaths || !Paths.All(IsUsable))
|
||||
{
|
||||
throw new InvalidDataException("Invalid document activation paths.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Names the single-instance group: one per executable folder and data folder, so stable and
|
||||
/// development editions, loose builds and test profiles never forward to each other.
|
||||
/// </summary>
|
||||
public static string InstanceKey(string executableDirectory, string dataDirectory)
|
||||
{
|
||||
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
|
||||
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The absolute form of one argument, or null when it cannot name a document.</summary>
|
||||
private static string? Resolve(string? path, string workingDirectory)
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
string full = Path.GetFullPath(path, workingDirectory);
|
||||
return IsUsable(full) ? full : null;
|
||||
}
|
||||
catch(Exception exception) when(exception is ArgumentException or NotSupportedException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A path the reader may be asked to open: absolute, bounded, and free of control characters.</summary>
|
||||
private static bool IsUsable(string? path)
|
||||
=> !string.IsNullOrEmpty(path) && path.Length <= MaximumPathCharacters && Path.IsPathFullyQualified(path) && !path.Any(char.IsControl);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
@@ -72,7 +73,7 @@ public static class AppIdentity
|
||||
|
||||
|
||||
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
|
||||
public static AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile);
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -202,9 +202,8 @@ public sealed class AppSettings : ShellSettings
|
||||
_ = Themes.ReaderTheme.ParseCompatible(json);
|
||||
return true;
|
||||
}
|
||||
catch(Exception exception) when(exception is JsonException or InvalidOperationException)
|
||||
catch(JsonException)
|
||||
{
|
||||
// A string the JSON reader cannot decode, such as an unpaired surrogate escape, is invalid content too.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.
|
||||
/// Reading tolerates what a hand edit commonly leaves behind: a trailing comma, a comment, a quoted number.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
/// <summary>Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.</summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
[JsonSerializable(typeof(AppSettings))]
|
||||
public sealed partial class AppSettingsContext : JsonSerializerContext
|
||||
{
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The settings file shared by every window and every process of one edition. A change is
|
||||
/// applied to the latest stored settings under a cross-process lock and renamed into place in
|
||||
/// one step, so two instances cannot erase each other's change and the file is never absent.
|
||||
/// A file that cannot be read, or that another program is writing, is never treated as empty;
|
||||
/// content that is invalid while nobody is writing it is repaired where possible and a copy of
|
||||
/// the original is kept aside. ADR-0006 describes the files involved.
|
||||
/// </summary>
|
||||
public sealed class AppSettingsStore
|
||||
{
|
||||
/// <summary>Attempts for the lock, a read, and the final rename before the operation gives up.</summary>
|
||||
public const int MaximumAttempts = 20;
|
||||
|
||||
/// <summary>A larger file is not this application's settings and is never loaded into memory.</summary>
|
||||
public const int MaximumBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>Copies of invalid files kept aside before the last name is reused.</summary>
|
||||
public const int MaximumKeptAside = 9;
|
||||
|
||||
/// <summary>The pause between attempts while another handle is in the way.</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private const int SharingViolation = unchecked((int)0x80070020);
|
||||
private static readonly JsonDocumentOptions s_lenient = new() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip };
|
||||
private readonly Action<SettingsFault, Exception?> _report;
|
||||
private readonly Action<TimeSpan> _wait;
|
||||
private readonly Func<string, string, bool> _renameOver;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store; faults go to <paramref name="report"/>, and tests supply <paramref name="wait"/> to count attempts instead of sleeping.</summary>
|
||||
public AppSettingsStore(string path, Action<SettingsFault, Exception?>? report = null, Action<TimeSpan>? wait = null)
|
||||
: this(path, report, wait, FileSwap.TryRenameOver)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store over a given rename, so tests can stand in for a volume that cannot rename over an open file.</summary>
|
||||
internal AppSettingsStore(string path, Action<SettingsFault, Exception?>? report, Action<TimeSpan>? wait, Func<string, string, bool> renameOver)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
Path = System.IO.Path.GetFullPath(path);
|
||||
_report = report ?? ((_, _) => { });
|
||||
_wait = wait ?? Thread.Sleep;
|
||||
_renameOver = renameOver;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The settings file, as an absolute path.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the stored settings for display. A missing, empty, invalid or unreadable file yields
|
||||
/// defaults and is left untouched. A handle that another program merely holds with write
|
||||
/// access does not delay the read.
|
||||
/// </summary>
|
||||
public AppSettings Load()
|
||||
{
|
||||
_ = TryRead(forUpdate: false, out AppSettings settings, out _);
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Applies one change to the latest stored settings and saves the result. Returns the saved
|
||||
/// settings, or null when nothing was written: the lock stayed busy, the file could not be
|
||||
/// read or was being written by another program, or the write failed. The stored file is
|
||||
/// unchanged in every null case.
|
||||
/// </summary>
|
||||
public AppSettings? Update(Func<AppSettings, AppSettings> change)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
FileStream? stored = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
|
||||
using FileStream? gate = Acquire();
|
||||
if(gate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// The read handle denies writers until the rename, so the content judged here is the content replaced.
|
||||
SettingsRead read = TryRead(forUpdate: true, out AppSettings current, out stored);
|
||||
if(read == SettingsRead.Unreadable)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
AppSettings edited = change(current);
|
||||
edited.Sanitize();
|
||||
RemoveAbandonedTemporaryFiles();
|
||||
string? copy = read is SettingsRead.Corrupt or SettingsRead.Repaired ? CopyAside(stored!) : null;
|
||||
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored, copy);
|
||||
if(copy is not null)
|
||||
{
|
||||
KeepAside(copy);
|
||||
}
|
||||
return edited;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_report(SettingsFault.WriteFailed, exception);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stored?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the copies of invalid files that saves have kept aside. They hold whatever the
|
||||
/// file held, including the recent-document history, so clearing that history removes them.
|
||||
/// </summary>
|
||||
public void DeleteKeptAside()
|
||||
{
|
||||
for(int slot = 1; slot <= MaximumKeptAside; slot++)
|
||||
{
|
||||
TryDelete(KeptAsideName(slot));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Holds an exclusive handle beside the settings file; the operating system releases it if the
|
||||
/// process dies. A lock that stays held, or cannot be opened for another I/O reason, is busy.
|
||||
/// </summary>
|
||||
private FileStream? Acquire()
|
||||
{
|
||||
try
|
||||
{
|
||||
FileStream? gate = null;
|
||||
Retry(() => gate = new FileStream(Path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None));
|
||||
return gate;
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
_report(SettingsFault.Busy, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the file, waiting out other handles a bounded number of times, and reports what it
|
||||
/// found. For an update the open handle is returned so writers stay out until the rename.
|
||||
/// </summary>
|
||||
private SettingsRead TryRead(bool forUpdate, out AppSettings settings, out FileStream? stored)
|
||||
{
|
||||
settings = new AppSettings();
|
||||
stored = null;
|
||||
SettingsRead? read = null;
|
||||
for(int attempt = 1; read is null; attempt++)
|
||||
{
|
||||
if(attempt > 1)
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
read = Attempt(forUpdate, attempt == MaximumAttempts, ref settings, ref stored);
|
||||
}
|
||||
if(read is SettingsRead.Corrupt or SettingsRead.Repaired)
|
||||
{
|
||||
_report(read == SettingsRead.Corrupt ? SettingsFault.Corrupt : SettingsFault.Repaired, null);
|
||||
}
|
||||
settings.Sanitize();
|
||||
return read.Value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One read. Returns null to try again: another handle is in the way, the file is empty while
|
||||
/// a save is waiting for whoever cleared it, or the content read beside a writer did not parse.
|
||||
/// On the last attempt it always answers.
|
||||
/// </summary>
|
||||
private SettingsRead? Attempt(bool forUpdate, bool last, ref AppSettings settings, ref FileStream? stored)
|
||||
{
|
||||
FileStream? stream = null;
|
||||
bool keep = false;
|
||||
try
|
||||
{
|
||||
stream = Open(forUpdate, out bool writersExcluded);
|
||||
SettingsRead read = Parse(stream, ref settings);
|
||||
if(read == SettingsRead.Empty && forUpdate && !last)
|
||||
{
|
||||
// A program that clears the file before rewriting it is about to write.
|
||||
return null;
|
||||
}
|
||||
if(read == SettingsRead.Corrupt && !writersExcluded)
|
||||
{
|
||||
// Content read while another program can write may simply be half written.
|
||||
return last ? Unreadable(null) : null;
|
||||
}
|
||||
keep = forUpdate;
|
||||
stored = keep ? stream : null;
|
||||
return read;
|
||||
}
|
||||
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return SettingsRead.Missing;
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !last)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Unreadable(exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!keep)
|
||||
{
|
||||
stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports a file whose content could not be established and answers that it must not be replaced.</summary>
|
||||
private SettingsRead Unreadable(Exception? exception)
|
||||
{
|
||||
_report(SettingsFault.Unreadable, exception);
|
||||
return SettingsRead.Unreadable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file denying writers, so a program rewriting it in place reads as busy rather
|
||||
/// than as invalid content. A read for display falls back to reading beside a handle that has
|
||||
/// write access, because such a handle is usually idle; the caller then knows the content may
|
||||
/// be half written.
|
||||
/// </summary>
|
||||
private FileStream Open(bool forUpdate, out bool writersExcluded)
|
||||
{
|
||||
writersExcluded = true;
|
||||
try
|
||||
{
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !forUpdate)
|
||||
{
|
||||
writersExcluded = false;
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads at most <see cref="MaximumBytes"/> and classifies the content; an invalid property costs that property, never the whole profile.</summary>
|
||||
private static SettingsRead Parse(FileStream stream, ref AppSettings settings)
|
||||
{
|
||||
if(stream.Length == 0)
|
||||
{
|
||||
return SettingsRead.Empty;
|
||||
}
|
||||
if(stream.Length > MaximumBytes)
|
||||
{
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
string json = reader.ReadToEnd();
|
||||
try
|
||||
{
|
||||
if(JsonSerializer.Deserialize(json, AppSettingsContext.Default.AppSettings) is { } loaded)
|
||||
{
|
||||
settings = loaded;
|
||||
return SettingsRead.Loaded;
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
if(Salvage(json) is { } repaired)
|
||||
{
|
||||
settings = repaired;
|
||||
return SettingsRead.Repaired;
|
||||
}
|
||||
}
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Keeps every stored property that is valid on its own and drops the ones that are not; null when the file is not a JSON object at all.</summary>
|
||||
private static AppSettings? Salvage(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(JsonNode.Parse(json, documentOptions: s_lenient) is not JsonObject stored)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
foreach(string name in stored.Select(property => property.Key).ToArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new JsonObject { [name] = stored[name]?.DeepClone() }.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
stored.Remove(name);
|
||||
}
|
||||
}
|
||||
return stored.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception) || exception is ArgumentException)
|
||||
{
|
||||
// Not JSON, or an object that repeats a property name.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the JSON reader throws for content that is not valid settings: a syntax or type error,
|
||||
/// or text it cannot decode, such as an unpaired surrogate escape.
|
||||
/// </summary>
|
||||
private static bool IsInvalidContent(Exception exception) => exception is JsonException or InvalidOperationException;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies an invalid file to a temporary name before it is replaced and returns that name.
|
||||
/// The copy is a courtesy, so the save goes ahead without one, with the reason reported and
|
||||
/// null returned, when the file is past the size bound (it was never this application's
|
||||
/// settings) or the copy cannot be written. A copy left behind by a save that never finished
|
||||
/// is removed with the other abandoned temporary files.
|
||||
/// </summary>
|
||||
private string? CopyAside(FileStream stored)
|
||||
{
|
||||
if(stored.Length > MaximumBytes)
|
||||
{
|
||||
_report(SettingsFault.NotKeptAside, null);
|
||||
return null;
|
||||
}
|
||||
string temporary = TemporaryName();
|
||||
try
|
||||
{
|
||||
using(var copy = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stored.Position = 0;
|
||||
stored.CopyTo(copy);
|
||||
copy.Flush(flushToDisk: true);
|
||||
}
|
||||
return temporary;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gives the copy of a replaced invalid file its lasting name: the first free kept-aside name,
|
||||
/// or the last one once all are taken. This happens after the save, so a save that fails
|
||||
/// never disturbs an earlier copy. The copy is a courtesy: one that cannot be placed is
|
||||
/// removed and reported, and the save stands.
|
||||
/// </summary>
|
||||
private void KeepAside(string copy)
|
||||
{
|
||||
int slot = 1;
|
||||
while(slot < MaximumKeptAside && (File.Exists(KeptAsideName(slot)) || Directory.Exists(KeptAsideName(slot))))
|
||||
{
|
||||
slot++;
|
||||
}
|
||||
try
|
||||
{
|
||||
File.Move(copy, KeptAsideName(slot), overwrite: true);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
TryDelete(copy);
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The kept-aside names: <c>.corrupt</c>, then <c>.corrupt-2</c> to <c>.corrupt-9</c>.</summary>
|
||||
private string KeptAsideName(int slot) => slot == 1 ? Path + ".corrupt" : Path + ".corrupt-" + slot.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
|
||||
/// <summary>A name beside the settings file that only this store uses, so an abandoned one can be recognized and removed.</summary>
|
||||
private string TemporaryName() => Path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes beside the file and flushes to disk, then renames over it in one step. A failure
|
||||
/// leaves the stored file as it was, and neither the temporary file nor the copy of the
|
||||
/// invalid original behind.
|
||||
/// </summary>
|
||||
private void Replace(byte[] content, FileStream? stored, string? copy)
|
||||
{
|
||||
string temporary = TemporaryName();
|
||||
bool replaced = false;
|
||||
try
|
||||
{
|
||||
using(var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(content);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if(stored is null)
|
||||
{
|
||||
// No overwrite: a file that appeared since the read is not this instance's to replace.
|
||||
File.Move(temporary, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool renamed = false;
|
||||
Retry(() => renamed = _renameOver(temporary, Path));
|
||||
if(!renamed)
|
||||
{
|
||||
// This volume cannot rename over an open file, so the file is closed first. This
|
||||
// move is refused while any handle is open on the file, a reader's included.
|
||||
stored.Dispose();
|
||||
Retry(() => File.Move(temporary, Path, overwrite: true), IsBlockedByAHandle);
|
||||
}
|
||||
}
|
||||
replaced = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!replaced)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
if(copy is not null)
|
||||
{
|
||||
TryDelete(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A writer that died before its rename left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
|
||||
private void RemoveAbandonedTemporaryFiles()
|
||||
{
|
||||
string prefix = System.IO.Path.GetFileName(Path) + ".";
|
||||
foreach(string file in Directory.EnumerateFiles(System.IO.Path.GetDirectoryName(Path)!, prefix + "*.tmp"))
|
||||
{
|
||||
string name = System.IO.Path.GetFileName(file);
|
||||
if(name.Length == prefix.Length + 36 && Guid.TryParseExact(name.AsSpan(prefix.Length, 32), "N", out _))
|
||||
{
|
||||
TryDelete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Repeats an operation only while another handle is in the way, a bounded number of times;
|
||||
/// the last failure is thrown. <paramref name="inTheWay"/> says which failures mean that:
|
||||
/// a sharing violation unless the operation reports an open handle differently.
|
||||
/// </summary>
|
||||
private void Retry(Action operation, Func<Exception, bool>? inTheWay = null)
|
||||
{
|
||||
inTheWay ??= IsTransient;
|
||||
for(int attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
operation();
|
||||
return;
|
||||
}
|
||||
catch(Exception exception) when(attempt < MaximumAttempts && inTheWay(exception))
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sharing violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
|
||||
private static bool IsTransient(Exception exception) => exception is IOException && exception.HResult == SharingViolation;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a move over the settings file fails with while another handle is open on it. Unlike
|
||||
/// an open or a rename, that move answers access denied, which therefore has to be waited
|
||||
/// out. A read-only file gives the same answer for good, so it is told apart by its attribute.
|
||||
/// </summary>
|
||||
private bool IsBlockedByAHandle(Exception exception)
|
||||
=> IsTransient(exception) || (exception is UnauthorizedAccessException && !new FileInfo(Path).IsReadOnly);
|
||||
|
||||
|
||||
|
||||
/// <summary>Removes a file if it is there and can be removed; a folder with that name, or a file in use, is left.</summary>
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Cleanup only: the operation this was part of has already succeeded or failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ public sealed class DocumentResourcePolicy
|
||||
".md", ".markdown", ".mdown", ".mkd", ".txt",
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -32,16 +38,6 @@ public sealed class DocumentResourcePolicy
|
||||
|
||||
|
||||
|
||||
/// <summary>The image file types served to the document surface.</summary>
|
||||
internal static IReadOnlyCollection<string> ImageExtensions => s_imageExtensions;
|
||||
|
||||
|
||||
|
||||
/// <summary>The document file types a link may open in a new reader.</summary>
|
||||
internal static IReadOnlyCollection<string> DocumentExtensions => s_documentExtensions;
|
||||
|
||||
|
||||
|
||||
/// <summary>The only top-level document URI the browser host may serve.</summary>
|
||||
public Uri DocumentUri { get; }
|
||||
|
||||
@@ -97,7 +93,7 @@ public sealed class DocumentResourcePolicy
|
||||
private static bool IsSafeSegment(string segment)
|
||||
{
|
||||
return segment.Length > 0 && segment is not ("." or "..") && !segment.EndsWith(' ') && !segment.EndsWith('.') &&
|
||||
!segment.Any(character => char.IsControl(character) || WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal)) &&
|
||||
!WindowsFileNames.IsReservedDeviceName(segment);
|
||||
!segment.Any(character => char.IsControl(character) || "<>:\\|?*".Contains(character, StringComparison.Ordinal)) &&
|
||||
!s_deviceNames.Contains(segment.Split('.')[0]);
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,6 @@ public sealed class MarkdownRenderer
|
||||
|
||||
|
||||
|
||||
/// <summary>The configured sanitizer, for comparing its allowlists with an independent list.</summary>
|
||||
internal HtmlSanitizer Policy => _sanitizer;
|
||||
|
||||
|
||||
|
||||
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
|
||||
public string Render(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide) => Render(markdown, frontmatter, null);
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a file by renaming another over it in a single step. The destination is never
|
||||
/// absent, not even for an instant, and the rename works while the destination is open through
|
||||
/// handles that allow deletion; those handles go on reading the content they opened.
|
||||
/// <see cref="File.Move(string, string, bool)"/> refuses when any handle is open on the
|
||||
/// destination, and <see cref="File.Replace(string, string, string?)"/> is two renames with the
|
||||
/// destination missing between them, so neither can do this.
|
||||
/// </summary>
|
||||
internal static partial class FileSwap
|
||||
{
|
||||
private const uint DeleteAccess = 0x00010000;
|
||||
private const uint ShareAll = 0x00000007;
|
||||
private const uint OpenExisting = 3;
|
||||
private const int FileRenameInfoEx = 22;
|
||||
private const uint ReplaceIfExists = 0x1;
|
||||
private const uint PosixSemantics = 0x2;
|
||||
private const int AccessDenied = 5;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renames <paramref name="source"/> over <paramref name="destination"/>. Returns false when the
|
||||
/// volume cannot rename over an open file, as file systems other than NTFS may not. Throws
|
||||
/// <see cref="IOException"/> with the Windows error as its HRESULT when the rename is refused,
|
||||
/// for example by a handle on the destination that does not allow deletion, and
|
||||
/// <see cref="UnauthorizedAccessException"/> when access is denied.
|
||||
/// </summary>
|
||||
public static bool TryRenameOver(string source, string destination)
|
||||
{
|
||||
using SafeFileHandle handle = CreateFile(Verbatim(source), DeleteAccess, ShareAll, 0, OpenExisting, 0, 0);
|
||||
if(handle.IsInvalid)
|
||||
{
|
||||
throw Failure(Marshal.GetLastPInvokeError());
|
||||
}
|
||||
// FILE_RENAME_INFO: flags, root directory handle, name length in bytes, then the name itself.
|
||||
// The name is copied as stored: a file name may hold text that is not well-formed UTF-16.
|
||||
byte[] name = MemoryMarshal.AsBytes(Verbatim(destination).AsSpan()).ToArray();
|
||||
int lengthOffset = 2 * IntPtr.Size;
|
||||
int nameOffset = lengthOffset + sizeof(int);
|
||||
byte[] rename = new byte[nameOffset + name.Length + sizeof(char)];
|
||||
BitConverter.TryWriteBytes(rename, ReplaceIfExists | PosixSemantics);
|
||||
BitConverter.TryWriteBytes(rename.AsSpan(lengthOffset), name.Length);
|
||||
name.CopyTo(rename, nameOffset);
|
||||
if(SetFileInformationByHandle(handle, FileRenameInfoEx, rename, rename.Length))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
return IsUnsupported(error) ? false : throw Failure(error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The form of a full path that Windows takes as written, without the 260-character limit the
|
||||
/// plain form has in a process that has not opted in to long paths. The managed file calls add
|
||||
/// it themselves; a direct Windows call has to.
|
||||
/// </summary>
|
||||
internal static string Verbatim(string fullPath)
|
||||
{
|
||||
if(fullPath.StartsWith(@"\\?\", StringComparison.Ordinal) || fullPath.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
return fullPath.StartsWith(@"\\", StringComparison.Ordinal) ? string.Concat(@"\\?\UNC\", fullPath.AsSpan(2)) : @"\\?\" + fullPath;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The errors a volume answers with when it does not implement this kind of rename: invalid function, not supported, invalid parameter.</summary>
|
||||
internal static bool IsUnsupported(int error) => error is 1 or 50 or 87;
|
||||
|
||||
|
||||
|
||||
/// <summary>The exception a managed file operation would throw for the same Windows error.</summary>
|
||||
private static Exception Failure(int error)
|
||||
{
|
||||
string message = new Win32Exception(error).Message;
|
||||
return error == AccessDenied ? new UnauthorizedAccessException(message) : new IOException(message, unchecked((int)0x80070000) | error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens the file to be renamed with delete access, which a managed file stream cannot request.</summary>
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
private static partial SafeFileHandle CreateFile(string path, uint access, uint share, nint security, uint disposition, uint flags, nint template);
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the rename to the open file.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, byte[] information, int size);
|
||||
}
|
||||
@@ -8,8 +8,6 @@
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<!-- Include the SDK tool dependency in the lock used by single-file CLI publishing. -->
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<!-- Source-generated P/Invoke marshalling (FileSwap) compiles to unsafe code. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>Why the settings file could not be used as stored; reported without paths or operating-system message text.</summary>
|
||||
public enum SettingsFault
|
||||
{
|
||||
/// <summary>The file does not hold settings at all; defaults are used and the next save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>Some stored properties were invalid and read as their defaults; the next save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>The file could not be read, or another program was writing it; it is left untouched and nothing is written over it.</summary>
|
||||
Unreadable,
|
||||
/// <summary>Another instance kept the settings lock for every attempt; the change was not saved.</summary>
|
||||
Busy,
|
||||
/// <summary>The change could not be written; the stored file is unchanged.</summary>
|
||||
WriteFailed,
|
||||
/// <summary>The invalid original could not be copied aside; the change was saved without the copy.</summary>
|
||||
NotKeptAside,
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>What reading the settings file found; only <see cref="Unreadable"/> forbids writing over it.</summary>
|
||||
internal enum SettingsRead
|
||||
{
|
||||
/// <summary>Valid stored settings.</summary>
|
||||
Loaded,
|
||||
/// <summary>No file yet; defaults apply and the first save creates it.</summary>
|
||||
Missing,
|
||||
/// <summary>A file with no content, as a program that clears a file before rewriting it leaves it; defaults apply and a save replaces it.</summary>
|
||||
Empty,
|
||||
/// <summary>Valid properties were kept and invalid ones dropped; a save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>Not settings at all; defaults apply and a save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>The content is unknown, so it must not be replaced.</summary>
|
||||
Unreadable,
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -16,24 +15,8 @@ namespace MarkdownViewr.Windows.Core.Themes;
|
||||
/// <summary>Validates Darin's version-one theme format before generating passive document CSS.</summary>
|
||||
public sealed class ReaderTheme
|
||||
{
|
||||
/// <summary>The largest theme file accepted for import; bounds the read before the complete JSON text is allocated.</summary>
|
||||
/// <summary>Bounds schema input before a native file read allocates the complete JSON text.</summary>
|
||||
public const int MaximumCharacters = 128 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The largest theme the app keeps. Renaming or editing rewrites the whole theme, and JSON
|
||||
/// writes some characters (emoji, no-break spaces, private-use glyphs) as six-character
|
||||
/// escapes, so a kept theme can be several times the size of the file it was imported from.
|
||||
/// The bound leaves room for that, so a theme that could be imported can always be renamed.
|
||||
/// </summary>
|
||||
public const int MaximumStoredCharacters = 8 * MaximumCharacters;
|
||||
|
||||
/// <summary>
|
||||
/// How a renamed or edited theme is written back. Quote marks, angle brackets and most
|
||||
/// non-ASCII letters are written as themselves instead of as six-character escapes, which
|
||||
/// keeps ordinary CSS and text close to its imported size. Theme JSON is stored and exported,
|
||||
/// never placed in a page.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions s_compact = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
private static readonly string[] s_colors = ["background", "text", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "link", "codeBackground", "codeText", "blockquoteBorder", "blockquoteBackground", "highlightBackground", "highlightText"];
|
||||
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
|
||||
private static readonly string s_baseStyles = LoadBaseStyles();
|
||||
@@ -76,7 +59,7 @@ public sealed class ReaderTheme
|
||||
{
|
||||
JsonNode source = JsonNode.Parse(Json)!;
|
||||
source["name"] = name;
|
||||
return ParseCompatible(source.ToJsonString(s_compact));
|
||||
return ParseCompatible(source.ToJsonString());
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +103,7 @@ public sealed class ReaderTheme
|
||||
}
|
||||
source["colors"]![key] = value?.Trim();
|
||||
}
|
||||
return ParseCompatible(source.ToJsonString(s_compact));
|
||||
return ParseCompatible(source.ToJsonString());
|
||||
}
|
||||
|
||||
|
||||
@@ -167,14 +150,13 @@ public sealed class ReaderTheme
|
||||
private static ReaderTheme ParseSchema(string json, bool allowNewer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(json);
|
||||
if(json.Length > MaximumStoredCharacters)
|
||||
if(json.Length > MaximumCharacters)
|
||||
{
|
||||
throw new JsonException("The theme is too large.");
|
||||
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
|
||||
}
|
||||
using JsonDocument document = ParseDocument(json);
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
JsonElement root = document.RootElement;
|
||||
RequireObject(root);
|
||||
RequireReadableText(root);
|
||||
int schemaVersion = 1;
|
||||
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out schemaVersion) || schemaVersion < 1 || !allowNewer && schemaVersion != 1))
|
||||
{
|
||||
@@ -296,68 +278,6 @@ public sealed class ReaderTheme
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the theme text as JSON; text that is not well-formed UTF-16 is an invalid theme like any other malformed input.</summary>
|
||||
private static JsonDocument ParseDocument(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
}
|
||||
catch(ArgumentException exception)
|
||||
{
|
||||
throw new JsonException("The theme contains text that cannot be decoded.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks every property name and string in the theme once, including ones this version does
|
||||
/// not read, because a kept theme is later renamed, edited and re-serialized as a whole. An
|
||||
/// unpaired surrogate escape is valid JSON syntax with no string value, and a repeated
|
||||
/// property name has no single value; both are invalid themes.
|
||||
/// </summary>
|
||||
private static void RequireReadableText(JsonElement root)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pending = new Stack<JsonElement>();
|
||||
pending.Push(root);
|
||||
while(pending.TryPop(out JsonElement element))
|
||||
{
|
||||
if(element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach(JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if(!names.Add(property.Name))
|
||||
{
|
||||
throw new JsonException("The theme repeats a property: " + property.Name);
|
||||
}
|
||||
pending.Push(property.Value);
|
||||
}
|
||||
}
|
||||
else if(element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach(JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
pending.Push(item);
|
||||
}
|
||||
}
|
||||
else if(element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_ = element.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(InvalidOperationException exception)
|
||||
{
|
||||
throw new JsonException("The theme contains text that cannot be decoded.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Prevents wrong-shaped JSON from reaching property accessors.</summary>
|
||||
private static void RequireObject(JsonElement element)
|
||||
{
|
||||
|
||||
@@ -33,13 +33,14 @@ public static class ThemeJson
|
||||
public static string SuggestedFileName(ReaderTheme theme)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(theme);
|
||||
string stem = string.Concat(theme.Name.Select(character => WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal) ? '_' : character))
|
||||
string stem = string.Concat(theme.Name.Select(character => "<>:\"/\\|?*".Contains(character, StringComparison.Ordinal) ? '_' : character))
|
||||
.Replace(' ', '-').ToLowerInvariant().Trim('.', '-');
|
||||
if(stem.Length == 0)
|
||||
{
|
||||
stem = "theme";
|
||||
}
|
||||
if(WindowsFileNames.IsReservedDeviceName(stem))
|
||||
string device = stem.Split('.')[0];
|
||||
if(device is "con" or "prn" or "aux" or "nul" || device.Length == 4 && (device.StartsWith("com", StringComparison.Ordinal) || device.StartsWith("lpt", StringComparison.Ordinal)) && device[3] is >= '1' and <= '9' or '¹' or '²' or '³')
|
||||
{
|
||||
stem = "theme-" + stem;
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The Windows file-name rules this application relies on, written out instead of asked of the
|
||||
/// running platform: the characters a name cannot contain and the names that address a device.
|
||||
/// </summary>
|
||||
public static class WindowsFileNames
|
||||
{
|
||||
/// <summary>Printable characters that cannot appear in a Windows file or folder name. Control characters are also forbidden; callers refuse those themselves.</summary>
|
||||
public const string InvalidCharacters = "<>:\"/\\|?*";
|
||||
|
||||
private static readonly HashSet<string> s_devices = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
|
||||
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
|
||||
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether a single name addresses a device under the legacy Win32 rule: any letter case, and
|
||||
/// ignoring everything after the first dot and any spaces before it, so <c>nul.txt</c> and
|
||||
/// <c>COM1 .log</c> count. Current Windows versions resolve fewer of these spellings to a
|
||||
/// device; the rule is applied in full on every version so the answer never depends on it.
|
||||
/// </summary>
|
||||
public static bool IsReservedDeviceName(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
int dot = name.IndexOf('.', StringComparison.Ordinal);
|
||||
return s_devices.Contains((dot < 0 ? name : name[..dot]).TrimEnd(' '));
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Bounded current-user IPC delivers sender-canonicalized document paths to the SDK's chosen primary instance.</summary>
|
||||
internal sealed class ActivationInbox : IDisposable
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<ActivationRequest> _pending = new();
|
||||
private readonly CancellationTokenSource _closed = new();
|
||||
private readonly NamedPipeServerStream _pipe;
|
||||
private readonly Action<Exception> _report;
|
||||
private readonly Task _listener;
|
||||
private Func<ActivationRequest, bool>? _dispatch;
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>The primary owns the pipe before XAML starts; only the same Windows user can connect.</summary>
|
||||
internal ActivationInbox(string key, Action<Exception> report)
|
||||
{
|
||||
_report = report;
|
||||
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
_listener = ListenAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The owned listener completes on disposal; tests and shutdown diagnostics can observe failures instead of losing a task.</summary>
|
||||
internal Task Completion => _listener;
|
||||
|
||||
|
||||
|
||||
/// <summary>Attaches the native UI dispatcher after launch, delivering bounded requests received while XAML initialized.</summary>
|
||||
internal void Attach(Func<ActivationRequest, bool> dispatch)
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
_dispatch = dispatch;
|
||||
while(_pending.TryDequeue(out ActivationRequest? request))
|
||||
{
|
||||
if(!dispatch(request))
|
||||
{
|
||||
_report(new IOException("The reader could not accept activation."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one fixed request with a bounded connection/acknowledgement wait and preserves the real server acknowledgement.</summary>
|
||||
internal static async Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.Validate();
|
||||
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
|
||||
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new ArgumentException("The activation payload is too large.", nameof(request));
|
||||
}
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await client.ConnectAsync(timeout.Token);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
|
||||
await client.WriteAsync(length, timeout.Token);
|
||||
await client.WriteAsync(payload, timeout.Token);
|
||||
await client.FlushAsync(timeout.Token);
|
||||
byte[] response = new byte[1];
|
||||
await client.ReadExactlyAsync(response, timeout.Token);
|
||||
return response[0] == 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A disconnected or malformed peer cannot stop later activations; each request has a bounded read deadline.</summary>
|
||||
private async Task ListenAsync()
|
||||
{
|
||||
while(!_closed.IsCancellationRequested)
|
||||
{
|
||||
bool connected = false;
|
||||
try
|
||||
{
|
||||
await _pipe.WaitForConnectionAsync(_closed.Token);
|
||||
connected = true;
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
byte[] length = new byte[4];
|
||||
await _pipe.ReadExactlyAsync(length, timeout.Token);
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
|
||||
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new InvalidDataException("Invalid activation length.");
|
||||
}
|
||||
byte[] payload = new byte[size];
|
||||
await _pipe.ReadExactlyAsync(payload, timeout.Token);
|
||||
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
|
||||
request.Validate();
|
||||
bool accepted;
|
||||
lock(_gate)
|
||||
{
|
||||
accepted = _dispatch is null ? QueueBeforeLaunch(request) : _dispatch(request);
|
||||
}
|
||||
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, timeout.Token);
|
||||
await _pipe.FlushAsync(timeout.Token);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or ArgumentException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
if(!_closed.IsCancellationRequested)
|
||||
{
|
||||
_report(exception);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(connected && !_closed.IsCancellationRequested)
|
||||
{
|
||||
// A broken read clears IsConnected, but the owned native connection still requires Disconnect.
|
||||
try
|
||||
{
|
||||
_pipe.Disconnect();
|
||||
}
|
||||
catch(ObjectDisposedException) when(_closed.IsCancellationRequested)
|
||||
{
|
||||
// Shutdown may release the handle after the cancellation check.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A startup burst can queue at most16 requests; the peer is told to retry if the native reader is busy.</summary>
|
||||
private bool QueueBeforeLaunch(ActivationRequest request)
|
||||
{
|
||||
if(_pending.Count >= 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pending.Enqueue(request);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Application shutdown cancels pending connections and releases the owned current-user pipe.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if(Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_closed.Cancel();
|
||||
_pipe.Dispose();
|
||||
_closed.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Only bounded literal absolute document paths cross the current-user activation pipe; no native commands are accepted.</summary>
|
||||
internal sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
|
||||
{
|
||||
internal const int MaximumPaths = 128;
|
||||
internal const int MaximumPayloadBytes = 1024 * 1024;
|
||||
|
||||
|
||||
|
||||
/// <summary>Canonicalizes at the sender, preserving relative-path meaning even when the existing reader has a different working directory.</summary>
|
||||
internal static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
|
||||
{
|
||||
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
|
||||
if(candidates.Length > MaximumPaths)
|
||||
{
|
||||
throw new ArgumentException("Too many document paths.", nameof(paths));
|
||||
}
|
||||
var request = new ActivationRequest(candidates.Select(path => Path.GetFullPath(path, workingDirectory)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
request.Validate();
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Malformed, oversized or relative pipe input cannot become an unbounded launch or be interpreted using the receiver's directory.</summary>
|
||||
internal void Validate()
|
||||
{
|
||||
if(Paths is null || Paths.Length > MaximumPaths || Paths.Any(path => string.IsNullOrEmpty(path) || path.Length > 32767 || !Path.IsPathFullyQualified(path) || path.Any(char.IsControl)))
|
||||
{
|
||||
throw new InvalidDataException("Invalid document activation paths.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Profile and executable directories define distinct stable/dev, development-build and review/test instance identities.</summary>
|
||||
internal static string InstanceKey(string executableDirectory, string dataDirectory)
|
||||
{
|
||||
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
|
||||
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
|
||||
@@ -107,14 +107,12 @@ public partial class App
|
||||
|
||||
|
||||
|
||||
/// <summary>Records a successfully committed path in the latest stored history under the settings lock, then refreshes every recent menu.</summary>
|
||||
/// <summary>Records a successfully committed path through the latest store snapshot, then refreshes every recent menu.</summary>
|
||||
internal bool RecordRecentDocument(string path)
|
||||
{
|
||||
bool saved = AppIdentity.SettingsStore.Update(settings =>
|
||||
{
|
||||
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
|
||||
return settings;
|
||||
}) is not null;
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
|
||||
bool saved = AppIdentity.SettingsStore.Save(settings);
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
@@ -124,16 +122,9 @@ public partial class App
|
||||
/// <summary>Clears only the local history, leaving open documents, reading preferences and files intact.</summary>
|
||||
internal bool ClearRecentDocuments()
|
||||
{
|
||||
bool saved = AppIdentity.SettingsStore.Update(settings =>
|
||||
{
|
||||
settings.RecentFiles = [];
|
||||
return settings;
|
||||
}) is not null;
|
||||
if(saved)
|
||||
{
|
||||
// Copies of an invalid settings file kept beside it would still list the history.
|
||||
AppIdentity.SettingsStore.DeleteKeptAside();
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.RecentFiles = [];
|
||||
bool saved = AppIdentity.SettingsStore.Save(settings);
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
using Velopack.Locators;
|
||||
|
||||
|
||||
@@ -30,8 +31,8 @@ internal static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>Each edition owns its own persisted preferences and recent document list; faults are logged by kind, never with a path or message text.</summary>
|
||||
public static Core.AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile, ReportSettingsFault);
|
||||
/// <summary>Each edition owns its own persisted preferences and recent document list.</summary>
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
|
||||
|
||||
|
||||
@@ -55,12 +56,6 @@ internal static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>A settings fault is an operational event: the kind, exception type and HRESULT identify it without disclosing the profile.</summary>
|
||||
private static void ReportSettingsFault(Core.SettingsFault fault, Exception? exception)
|
||||
=> App.Log.Warning(FormattableString.Invariant($"Settings file fault: {fault}; exception={exception?.GetType().Name ?? "none"}; HRESULT=0x{exception?.HResult ?? 0:X8}."));
|
||||
|
||||
|
||||
|
||||
/// <summary>Daily activity files use the edition's own path and identity prefix.</summary>
|
||||
public static IActivityLog CreateLog() => FileActivityLog.ForApp(Info, Paths);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
using Microsoft.Windows.AppLifecycle;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ public sealed partial class MainWindow
|
||||
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
|
||||
draft.ConfiguredEditors = choices;
|
||||
draft.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
|
||||
if(merged is null)
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
{
|
||||
StatusText.Text = "Could not save editor configuration.";
|
||||
return;
|
||||
|
||||
@@ -19,12 +19,9 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.Print = options.Sanitize();
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.Print = options.Sanitize();
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ public sealed partial class MainWindow
|
||||
draft.ContentWidthEnabled = appearance.LimitWidth;
|
||||
draft.ContentWidthPx = appearance.WidthPixels;
|
||||
draft.GlobalCss = appearance.GlobalCss;
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
|
||||
if(merged is null)
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -79,16 +79,14 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.ShowSplashScreen = showSplash;
|
||||
current.SplashMilliseconds = milliseconds;
|
||||
current.CheckForUpdatesOnStartup = checkUpdates;
|
||||
current.ShowOutlineByDefault = showOutline;
|
||||
current.ShowSourceByDefault = showSource;
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.ShowSplashScreen = showSplash;
|
||||
settings.SplashMilliseconds = milliseconds;
|
||||
settings.CheckForUpdatesOnStartup = checkUpdates;
|
||||
settings.ShowOutlineByDefault = showOutline;
|
||||
settings.ShowSourceByDefault = showSource;
|
||||
settings.Sanitize();
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -122,13 +120,10 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.WrapOutlineEntries = wrap;
|
||||
current.ShowOutlineDepthMarkers = depthMarkers;
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.WrapOutlineEntries = wrap;
|
||||
settings.ShowOutlineDepthMarkers = depthMarkers;
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ public sealed partial class MainWindow
|
||||
/// <summary>Failed persistence does not apply an unsaved theme draft; sibling fields untouched by this edit retain their latest values.</summary>
|
||||
private async Task<bool> CommitThemeDraftAsync(Core.AppSettings before, Core.AppSettings edited)
|
||||
{
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, edited));
|
||||
if(merged is null)
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, edited);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
{
|
||||
StatusText.Text = "Could not save document themes.";
|
||||
return false;
|
||||
|
||||
@@ -522,7 +522,8 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
|
||||
private void Save()
|
||||
{
|
||||
if(AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, _lastSavedSettings, _settings)) is not null)
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), _lastSavedSettings, _settings);
|
||||
if(AppIdentity.SettingsStore.Save(merged))
|
||||
{
|
||||
_lastSavedSettings = Core.SettingsChanges.Copy(_settings);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.Windows.AppLifecycle;
|
||||
using Velopack;
|
||||
@@ -16,17 +17,21 @@ namespace MarkdownViewr.Windows;
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>The primary's bounded current-user inbox is attached by App only after the native reader initializes.</summary>
|
||||
/// <summary>The running reader's inbox; null in a process that could not create one or that opened its documents itself.</summary>
|
||||
internal static ActivationInbox? Inbox { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The initial canonical request preserves sender-relative paths and SDK rich file activation.</summary>
|
||||
/// <summary>The documents this launch was asked to open, resolved against its own working folder.</summary>
|
||||
internal static ActivationRequest InitialRequest { get; private set; } = new([]);
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request.</summary>
|
||||
/// <summary>
|
||||
/// Runs the installer hooks, then either hands this launch's documents to the reader that is
|
||||
/// already running or becomes a reader. A launch whose documents the running reader could not
|
||||
/// take opens them itself, so a double-clicked file always opens.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
private static int Main()
|
||||
{
|
||||
@@ -39,12 +44,12 @@ public static class Program
|
||||
WinRT.ComWrappersSupport.InitializeComWrappers();
|
||||
InitialRequest = DesktopActivation.ReadInitialRequest();
|
||||
string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!);
|
||||
var primary = AppInstance.FindOrRegisterForKey(key);
|
||||
if(!primary.IsCurrent)
|
||||
var instance = AppInstance.FindOrRegisterForKey(key);
|
||||
if(!instance.IsCurrent && Forward(key, instance.ProcessId))
|
||||
{
|
||||
return ForwardToPrimary(key, primary.ProcessId);
|
||||
return 0;
|
||||
}
|
||||
using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure);
|
||||
ActivationInbox? inbox = instance.IsCurrent ? OpenInbox(key) : null;
|
||||
Inbox = inbox;
|
||||
try
|
||||
{
|
||||
@@ -57,30 +62,54 @@ public static class Program
|
||||
finally
|
||||
{
|
||||
Inbox = null;
|
||||
primary.UnregisterKey();
|
||||
// The pipe name is released before the instance key, so the next reader to register can create its own inbox.
|
||||
inbox?.Dispose();
|
||||
if(instance.IsCurrent)
|
||||
{
|
||||
instance.UnregisterKey();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Current-user .NET pipe delivery avoids the SDK STA redirection deadlock and resolves relative paths before crossing processes.</summary>
|
||||
private static int ForwardToPrimary(string key, uint processId)
|
||||
/// <summary>
|
||||
/// Creates the inbox, or returns null when the pipe name is taken. The reader then runs
|
||||
/// without one: later launches find nobody listening and open their documents themselves.
|
||||
/// </summary>
|
||||
private static ActivationInbox? OpenInbox(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new ActivationInbox(key, DesktopActivation.ReportFailure);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
DesktopActivation.ReportFailure(exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hands this launch's documents to the running reader; false when it did not take them.</summary>
|
||||
private static bool Forward(string key, uint processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
DesktopActivation.AllowPrimaryForeground(processId);
|
||||
bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if(!delivered)
|
||||
// A plain pipe is used because the SDK's own redirection deadlocks an STA entry point.
|
||||
if(ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult())
|
||||
{
|
||||
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
|
||||
return true;
|
||||
}
|
||||
return delivered ? 0 : 1;
|
||||
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException)
|
||||
{
|
||||
DesktopActivation.ReportFailure(exception);
|
||||
return 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -6,9 +6,10 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Fuzzes the real generated activation schema and path validator without creating a window, pipe, process or profile.</summary>
|
||||
[Trait("Category", "Fuzz")]
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Real current-user pipes between a real inbox and real senders. Deadlines run on a manual
|
||||
/// clock, so no test waits for one and none can be reached early by a slow machine.
|
||||
/// </summary>
|
||||
public sealed class ActivationInboxTests
|
||||
{
|
||||
private static readonly TimeSpan s_safetyNet = TimeSpan.FromSeconds(30);
|
||||
|
||||
|
||||
|
||||
/// <summary>A request sent while the reader is starting is held until it attaches; afterwards each request gets the reader's own answer.</summary>
|
||||
[Fact]
|
||||
public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Empty(received);
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
inbox.Attach(_ => false);
|
||||
Assert.False(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>While the reader is starting it holds a bounded number of requests and refuses the next, which the sender is told.</summary>
|
||||
[Fact]
|
||||
public async Task Send_MoreThanTheBoundBeforeTheReaderAttaches_RefusesTheExtraRequest()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
using var inbox = new ActivationInbox(key, _ => { });
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
for(int index = 0; index < ActivationInbox.MaximumPendingRequests; index++)
|
||||
{
|
||||
Assert.True(await ActivationInbox.SendAsync(key, ActivationRequest.Create([$"held{index}.md"], Path.GetTempPath()), safety.Token));
|
||||
}
|
||||
Assert.False(await ActivationInbox.SendAsync(key, ActivationRequest.Create(["one too many.md"], Path.GetTempPath()), safety.Token));
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
Assert.Equal(Enumerable.Range(0, ActivationInbox.MaximumPendingRequests).Select(index => $"held{index}.md"), received.Select(request => Path.GetFileName(Assert.Single(request.Paths))), StringComparer.Ordinal);
|
||||
Assert.Equal(16, ActivationInbox.MaximumPendingRequests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A held request the reader then cannot take is reported, not silently dropped.</summary>
|
||||
[Fact]
|
||||
public async Task Attach_HeldRequestTheReaderRefuses_IsReported()
|
||||
{
|
||||
string key = NewKey();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
inbox.Attach(_ => false);
|
||||
Assert.IsType<IOException>(Assert.Single(errors));
|
||||
Assert.Throws<ArgumentNullException>(() => inbox.Attach(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new ActivationInbox(NewKey(), null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The pipe name belongs to one inbox at a time. A second one fails to start, so its process
|
||||
/// can run without an inbox, and the name is free again once the first is disposed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Constructor_NameAlreadyTaken_ThrowsUntilTheOwnerIsDisposed()
|
||||
{
|
||||
string key = NewKey();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Task stopped;
|
||||
using(var owner = new ActivationInbox(key, _ => { }))
|
||||
{
|
||||
Assert.ThrowsAny<IOException>(() => new ActivationInbox(key, _ => { }));
|
||||
stopped = owner.Completion;
|
||||
}
|
||||
await stopped.WaitAsync(safety.Token);
|
||||
using var successor = new ActivationInbox(key, _ => { });
|
||||
successor.Attach(_ => true);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A frame with a zero, negative or oversized length, an unknown field, a relative path or a null body is rejected; the next valid sender is still served.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_MalformedPeers_RejectsAndRecovers()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await SendFrame(key, ActivationRequest.MaximumPayloadBytes + 1, [], safety.Token);
|
||||
await SendFrame(key, 0, [], safety.Token);
|
||||
await SendFrame(key, -1, [], safety.Token);
|
||||
foreach(string body in new[] { "{\"Paths\":[],\"Command\":\"execute\"}", "{\"Paths\":[\"relative.md\"]}", "null" })
|
||||
{
|
||||
byte[] payload = Encoding.UTF8.GetBytes(body);
|
||||
await SendFrame(key, payload.Length, payload, safety.Token);
|
||||
}
|
||||
var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
// Each frame is refused for its own reason: the three lengths before any payload is read, then the schema, the path rule and the empty body.
|
||||
Type[] expected = [typeof(InvalidDataException), typeof(InvalidDataException), typeof(InvalidDataException), typeof(System.Text.Json.JsonException), typeof(InvalidDataException), typeof(InvalidDataException)];
|
||||
Assert.Equal(expected, errors.Select(error => error.GetType()));
|
||||
Assert.Equal(1024 * 1024, ActivationRequest.MaximumPayloadBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A frame of exactly the largest size is read and accepted.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_FrameOfExactlyTheLargestSize_IsAccepted()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
byte[] payload = new byte[ActivationRequest.MaximumPayloadBytes];
|
||||
Array.Fill(payload, (byte)' ');
|
||||
"{\"Paths\":[\"C:\\\\largest.md\"]}"u8.CopyTo(payload);
|
||||
Assert.Equal(1, await SendFrame(key, payload.Length, payload, safety.Token));
|
||||
Assert.Equal([@"C:\largest.md"], Assert.Single(received).Paths);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sender never puts an invalid or oversized request on the pipe.</summary>
|
||||
[Fact]
|
||||
public async Task Send_InvalidOrOversizedRequest_IsRefusedBeforeConnecting()
|
||||
{
|
||||
string key = NewKey();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => ActivationInbox.SendAsync(key, new ActivationRequest(["relative.md"]), safety.Token));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => ActivationInbox.SendAsync(key, null!, safety.Token));
|
||||
string longPath = @"C:\" + new string('a', 9000);
|
||||
var oversized = new ActivationRequest(Enumerable.Range(0, ActivationRequest.MaximumPaths).Select(index => longPath + index.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray());
|
||||
oversized.Validate();
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => ActivationInbox.SendAsync(key, oversized, safety.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>With no reader listening, a sender gives up at its connect deadline instead of waiting forever.</summary>
|
||||
[Fact]
|
||||
public async Task Send_NoReaderListening_GivesUpAtTheConnectDeadline()
|
||||
{
|
||||
var clock = new ManualTimeProvider();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Task<bool> sending = ActivationInbox.SendAsync(NewKey(), new ActivationRequest([]), clock, safety.Token);
|
||||
await clock.NextTimerAsync(safety.Token);
|
||||
Assert.False(sending.IsCompleted);
|
||||
clock.Elapse();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sending);
|
||||
Assert.False(safety.IsCancellationRequested);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), ActivationInbox.ConnectDeadline);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A peer that connects and sends nothing is dropped at the exchange deadline, and the next sender is served.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_PeerThatSendsNothing_IsDroppedAtTheDeadline()
|
||||
{
|
||||
string key = NewKey();
|
||||
var clock = new ManualTimeProvider();
|
||||
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception), clock);
|
||||
inbox.Attach(_ => true);
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await using(var silent = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await silent.ConnectAsync(safety.Token);
|
||||
await clock.NextTimerAsync(safety.Token);
|
||||
Assert.False(reported.Task.IsCompleted);
|
||||
clock.Elapse();
|
||||
Assert.IsAssignableFrom<OperationCanceledException>(await reported.Task.WaitAsync(safety.Token));
|
||||
}
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), ActivationInbox.ExchangeDeadline);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sender that disconnects without a request is reported and does not block the next one; disposing twice is harmless and frees the name.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
|
||||
{
|
||||
string key = NewKey();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Task completed;
|
||||
using(var inbox = new ActivationInbox(key, errors.Enqueue))
|
||||
{
|
||||
inbox.Attach(_ => true);
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(safety.Token);
|
||||
}
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
Assert.Single(errors);
|
||||
completed = inbox.Completion;
|
||||
inbox.Dispose();
|
||||
inbox.Dispose();
|
||||
}
|
||||
await completed.WaitAsync(safety.Token);
|
||||
Assert.Single(errors);
|
||||
using var replacement = new ActivationInbox(key, _ => { });
|
||||
replacement.Attach(_ => true);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A peer that has already closed when the reader turns to it is released before the next sender connects.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
|
||||
{
|
||||
string key = NewKey();
|
||||
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var connectionContext = new DeferredConnectionContext();
|
||||
SynchronizationContext? previous = SynchronizationContext.Current;
|
||||
ActivationInbox inbox;
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(connectionContext);
|
||||
inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(previous);
|
||||
}
|
||||
using ActivationInbox ownedInbox = inbox;
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(safety.Token);
|
||||
}
|
||||
(SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(safety.Token);
|
||||
// Resume the listener only after the peer has closed, so its first read finds a broken pipe.
|
||||
var resume = Task.Run(() => callback(state), safety.Token);
|
||||
Assert.IsAssignableFrom<IOException>(await reported.Task.WaitAsync(safety.Token));
|
||||
var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
await resume.WaitAsync(safety.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A pipe name no other test or process uses.</summary>
|
||||
private static string NewKey() => "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one raw frame and returns the reader's answer byte, or -1 when the reader closed the connection without answering.</summary>
|
||||
private static async Task<int> SendFrame(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await client.ConnectAsync(cancellationToken);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
|
||||
byte[] response = new byte[1];
|
||||
try
|
||||
{
|
||||
await client.WriteAsync(length, cancellationToken);
|
||||
await client.WriteAsync(payload, cancellationToken);
|
||||
await client.FlushAsync(cancellationToken);
|
||||
return await client.ReadAsync(response, cancellationToken) == 1 ? response[0] : -1;
|
||||
}
|
||||
catch(IOException)
|
||||
{
|
||||
// Windows may report a broken pipe instead of end-of-stream once the reader has rejected the frame.
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Captures the listener's first continuation so a test decides when it resumes.</summary>
|
||||
private sealed class DeferredConnectionContext : SynchronizationContext
|
||||
{
|
||||
/// <summary>The captured continuation and its state.</summary>
|
||||
internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>Holds the first posted continuation instead of running it.</summary>
|
||||
public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>What one launch may ask the running reader to open, and which launches share a reader.</summary>
|
||||
public sealed class ActivationRequestTests
|
||||
{
|
||||
/// <summary>Relative arguments resolve against the launching folder; shell characters stay literal and duplicate paths collapse without regard to case.</summary>
|
||||
[Fact]
|
||||
public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "sender space");
|
||||
var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
|
||||
Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
|
||||
Assert.False(request.HasInvalidPath);
|
||||
Assert.Empty(ActivationRequest.Create([], directory).Paths);
|
||||
Assert.False(ActivationRequest.Create([], directory).HasInvalidPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An argument that cannot name a document is dropped and flagged; the documents beside it still open.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("bad\0name.md")]
|
||||
[InlineData("line\nbreak.md")]
|
||||
public void Create_UnusableArgumentBesideUsableOnes_KeepsTheUsableOnesAndFlagsTheRest(string unusable)
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "sender");
|
||||
var request = ActivationRequest.Create(["first.md", unusable, "second.md"], directory);
|
||||
Assert.Equal(new[] { Path.Combine(directory, "first.md"), Path.Combine(directory, "second.md") }, request.Paths);
|
||||
Assert.True(request.HasInvalidPath);
|
||||
request.Validate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A launch may name at most the bounded number of documents; one more is refused outright.</summary>
|
||||
[Fact]
|
||||
public void Create_PathCountAtAndPastTheBound_IsBoundedExactly()
|
||||
{
|
||||
string directory = Path.GetTempPath();
|
||||
IEnumerable<string> names = Enumerable.Range(0, ActivationRequest.MaximumPaths + 1).Select(index => $"document{index}.md");
|
||||
Assert.Equal(ActivationRequest.MaximumPaths, ActivationRequest.Create(names.Take(ActivationRequest.MaximumPaths), directory).Paths.Length);
|
||||
Assert.Throws<ArgumentException>(() => ActivationRequest.Create(names, directory));
|
||||
Assert.Throws<ArgumentNullException>(() => ActivationRequest.Create(null!, directory));
|
||||
Assert.Equal(128, ActivationRequest.MaximumPaths);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A request that did not come from Create is refused: relative, empty, control-bearing, overlong or too many paths.</summary>
|
||||
[Fact]
|
||||
public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(null!).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["relative.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([""]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([null!]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths + 1).ToArray()).Validate());
|
||||
new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths).ToArray()).Validate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A path is accepted up to the Windows limit and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void Validate_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = @"C:\" + new string('a', 32767 - 3);
|
||||
Assert.Equal(32767, atLimit.Length);
|
||||
new ActivationRequest([atLimit]).Validate();
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([atLimit + "a"]).Validate());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One reader serves one executable folder and one data folder. Spelling does not matter:
|
||||
/// case and a trailing separator name the same folders. Any other folder is another reader,
|
||||
/// even one whose name has the same length.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "viewer");
|
||||
string data = Path.Combine(Path.GetTempPath(), "profile");
|
||||
string key = ActivationRequest.InstanceKey(root, data);
|
||||
Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
|
||||
Assert.Equal(key, ActivationRequest.InstanceKey(root.ToUpperInvariant(), data + Path.DirectorySeparatorChar));
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, Path.Combine(Path.GetTempPath(), "profilf")), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(data, root), StringComparer.Ordinal);
|
||||
Assert.Matches("^k22-markdownviewr-[0-9A-F]{64}$", key);
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,8 @@ public class AppSettingsTests
|
||||
public void SaveAndLoad_KeepsTheTheme()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"));
|
||||
Assert.NotNull(store.Update(_ => new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
Assert.True(store.Save(new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
Assert.Equal("GitHub Light", store.Load().DocumentTheme);
|
||||
Assert.Equal(Documents.FrontmatterMode.Metadata, store.Load().FrontmatterKind);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,217 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Each resource-policy check is exercised alone: a request that differs from a valid one in a
|
||||
/// single component must be denied by that component's check, not by an unrelated one.
|
||||
/// </summary>
|
||||
public sealed class DocumentResourcePolicyBoundaryTests
|
||||
{
|
||||
/// <summary>The valid baseline every single-component variation below is derived from.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_OwnDocumentRequest_IsAccepted()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal("image.png", policy.GetImageRelativePath(Vary(policy, "image.png", _ => { })));
|
||||
Assert.Equal("child.md", policy.GetDocumentRelativePath(Vary(policy, "child.md", _ => { })));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Plain HTTP on the same host, port and path is not the document's origin.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_OtherScheme_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Uri image = Vary(policy, "image.png", builder =>
|
||||
{
|
||||
builder.Scheme = "http";
|
||||
builder.Port = 443;
|
||||
});
|
||||
Assert.Equal(443, image.Port);
|
||||
Assert.Null(policy.GetImageRelativePath(image));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
|
||||
{
|
||||
builder.Scheme = "http";
|
||||
builder.Port = 443;
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Another port on the same host is another origin.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_OtherPort_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Port = 444)));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Port = 8443)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Credentials in the request never reach a resource.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_UserInformation_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.UserName = "user")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
|
||||
{
|
||||
builder.UserName = "user";
|
||||
builder.Password = "secret";
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Another host is denied, including a subdomain of the viewer's host and a host that merely starts with it.</summary>
|
||||
[Theory]
|
||||
[InlineData("example.com")]
|
||||
[InlineData("sub.viewer.markdownviewr.invalid")]
|
||||
[InlineData("viewer.markdownviewr.invalid.example.com")]
|
||||
public void GetRelativePath_OtherHost_IsDenied(string host)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Host = host)));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Host = host)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The served image types and the openable document types are exactly these sets.</summary>
|
||||
[Fact]
|
||||
public void Extensions_ConfiguredSets_AreExactlyTheListedTypes()
|
||||
{
|
||||
Assert.Equal([".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"], DocumentResourcePolicy.ImageExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Equal([".markdown", ".md", ".mdown", ".mkd", ".txt"], DocumentResourcePolicy.DocumentExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The document's own path prefix is matched exactly; another letter case is another document.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_PrefixInAnotherLetterCase_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
string shouted = policy.DocumentUri.AbsoluteUri.Replace("/documents/", "/DOCUMENTS/", StringComparison.Ordinal);
|
||||
Assert.NotEqual(policy.DocumentUri.AbsoluteUri, shouted, StringComparer.Ordinal);
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(new Uri(shouted), "image.png")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(new Uri(shouted), "child.md")));
|
||||
Assert.Null(policy.GetHeadingIndex(new Uri(shouted + "#mv-heading-0"), 3));
|
||||
Assert.Equal(0, policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + "#mv-heading-0"), 3));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Image types are served in any letter case; documents, pages, scripts and executables are not images.</summary>
|
||||
[Theory]
|
||||
[InlineData(".png", true)]
|
||||
[InlineData(".jpg", true)]
|
||||
[InlineData(".jpeg", true)]
|
||||
[InlineData(".gif", true)]
|
||||
[InlineData(".bmp", true)]
|
||||
[InlineData(".webp", true)]
|
||||
[InlineData(".svg", true)]
|
||||
[InlineData(".avif", true)]
|
||||
[InlineData(".ico", true)]
|
||||
[InlineData(".PNG", true)]
|
||||
[InlineData(".md", false)]
|
||||
[InlineData(".markdown", false)]
|
||||
[InlineData(".txt", false)]
|
||||
[InlineData(".html", false)]
|
||||
[InlineData(".htm", false)]
|
||||
[InlineData(".js", false)]
|
||||
[InlineData(".css", false)]
|
||||
[InlineData(".json", false)]
|
||||
[InlineData(".pdf", false)]
|
||||
[InlineData(".tiff", false)]
|
||||
[InlineData(".exe", false)]
|
||||
[InlineData(".lnk", false)]
|
||||
[InlineData(".url", false)]
|
||||
[InlineData("", false)]
|
||||
public void GetImageRelativePath_Extension_ServesOnlyImageTypes(string extension, bool served)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal(served ? "file" + extension : null, policy.GetImageRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Document types open in a new reader in any letter case; pages, images, scripts and executables do not.</summary>
|
||||
[Theory]
|
||||
[InlineData(".md", true)]
|
||||
[InlineData(".markdown", true)]
|
||||
[InlineData(".mdown", true)]
|
||||
[InlineData(".mkd", true)]
|
||||
[InlineData(".txt", true)]
|
||||
[InlineData(".MD", true)]
|
||||
[InlineData(".html", false)]
|
||||
[InlineData(".htm", false)]
|
||||
[InlineData(".png", false)]
|
||||
[InlineData(".svg", false)]
|
||||
[InlineData(".json", false)]
|
||||
[InlineData(".pdf", false)]
|
||||
[InlineData(".exe", false)]
|
||||
[InlineData(".bat", false)]
|
||||
[InlineData(".cmd", false)]
|
||||
[InlineData(".ps1", false)]
|
||||
[InlineData(".lnk", false)]
|
||||
[InlineData(".url", false)]
|
||||
[InlineData(".mdx", false)]
|
||||
[InlineData("", false)]
|
||||
public void GetDocumentRelativePath_Extension_OpensOnlyDocumentTypes(string extension, bool opened)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal(opened ? "file" + extension : null, policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Each character Windows forbids in a name is refused on its own, for images and documents.</summary>
|
||||
[Theory]
|
||||
[InlineData("%3C")]
|
||||
[InlineData("%3E")]
|
||||
[InlineData("%3A")]
|
||||
[InlineData("%22")]
|
||||
[InlineData("%5C")]
|
||||
[InlineData("%7C")]
|
||||
[InlineData("%3F")]
|
||||
[InlineData("%2A")]
|
||||
public void GetRelativePath_ForbiddenCharacter_IsDenied(string encoded)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "images/a" + encoded + "b.png")));
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "im" + encoded + "ages/ab.png")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/a" + encoded + "b.md")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The generated heading fragment is matched exactly; another letter case is not a heading target.</summary>
|
||||
[Theory]
|
||||
[InlineData("#MV-HEADING-0")]
|
||||
[InlineData("#Mv-Heading-0")]
|
||||
public void GetHeadingIndex_OtherSpelling_IsNotAHeadingTarget(string fragment)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + fragment), 3));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds this document's own resource request and changes exactly one component of it.</summary>
|
||||
private static Uri Vary(DocumentResourcePolicy policy, string file, Action<UriBuilder> change)
|
||||
{
|
||||
var builder = new UriBuilder(new Uri(policy.DocumentUri, file));
|
||||
change(builder);
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
@@ -76,24 +76,21 @@ public sealed class EditorConfigurationTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Atomic persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
|
||||
/// <summary>Atomic AppKit persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
|
||||
[Fact]
|
||||
public void SaveAndLoad_EditorList_PreservesIdentityOrderAndMode()
|
||||
{
|
||||
using var temporary = new TempDirectory();
|
||||
var store = new AppSettingsStore(temporary.File("settings.json"));
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temporary.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
var first = new EditorConfiguration(Guid.NewGuid(), "Folder editor", @"C:\Editor\folder.exe", true);
|
||||
var second = new EditorConfiguration(Guid.NewGuid(), "File editor", @"C:\Editor\file.exe");
|
||||
Assert.NotNull(store.Update(_ => new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
|
||||
Assert.True(store.Save(new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
|
||||
AppSettings loaded = store.Load();
|
||||
Assert.Equal(new[] { first, second }, loaded.ConfiguredEditors);
|
||||
Assert.Equal(first.Executable, loaded.ExternalEditor);
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.ConfiguredEditors = [];
|
||||
settings.ExternalEditor = "";
|
||||
return settings;
|
||||
}));
|
||||
loaded.ConfiguredEditors = [];
|
||||
loaded.ExternalEditor = "";
|
||||
Assert.True(store.Save(loaded));
|
||||
Assert.Empty(store.Load().ConfiguredEditors);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Renaming one real file over another, with other real handles open on the destination.</summary>
|
||||
public sealed class FileSwapTests
|
||||
{
|
||||
/// <summary>A full path is given the form Windows takes as written: a drive path, a network path, and paths that already have it.</summary>
|
||||
[Theory]
|
||||
[InlineData(@"C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
||||
[InlineData(@"\\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
||||
[InlineData(@"\\?\C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
||||
[InlineData(@"\\?\UNC\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
||||
[InlineData(@"\\.\C:\data\settings.json", @"\\.\C:\data\settings.json")]
|
||||
public void Verbatim_FullPath_IsPrefixedOnce(string path, string expected)
|
||||
{
|
||||
Assert.Equal(expected, FileSwap.Verbatim(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The rename replaces the destination and removes the source name.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ExistingDestination_ReplacesIt()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.False(File.Exists(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A reader that allows deletion does not stop the rename, and goes on reading the content it opened.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenByAReaderThatAllowsDeletion_ReplacesItUnderTheReader()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old content");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(var reader = new StreamReader(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)))
|
||||
{
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("old content", reader.ReadToEnd());
|
||||
}
|
||||
Assert.Equal(["stored.json"], Directory.GetFiles(temp.Path).Select(file => Path.GetFileName(file)!), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A handle that does not allow deletion refuses the rename with a sharing violation, and both files stay as they were.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenWithoutDeleteSharing_IsASharingViolation()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
IOException refused = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070020), refused.HResult);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A read-only destination is access denied, as it is for a managed move.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ReadOnlyDestination_IsAccessDenied()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.ReadOnly);
|
||||
try
|
||||
{
|
||||
Assert.Throws<UnauthorizedAccessException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.Normal);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A source that does not exist is reported with the Windows error for a missing file.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_MissingSource_IsAnIoFailure()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
IOException missing = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("absent.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070002), missing.HResult);
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A destination that does not exist yet is simply created by the rename.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_NoDestinationYet_RenamesTheSource()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the errors a volume gives for an unimplemented rename mean "use another way"; every other error is a failure.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(50, true)]
|
||||
[InlineData(87, true)]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(5, false)]
|
||||
[InlineData(32, false)]
|
||||
[InlineData(17, false)]
|
||||
public void IsUnsupported_WindowsError_SeparatesAnUnimplementedRenameFromAFailure(int error, bool unsupported)
|
||||
=> Assert.Equal(unsupported, FileSwap.IsUnsupported(error));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A clock whose timers fire only when a test says so, so a deadline can be reached without
|
||||
/// waiting for it and can never be reached by a slow machine.
|
||||
/// </summary>
|
||||
internal sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly List<ManualTimer> _timers = [];
|
||||
private TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records the timer instead of scheduling it.</summary>
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ManualTimer(callback, state);
|
||||
lock(_gate)
|
||||
{
|
||||
_timers.Add(timer);
|
||||
_created.TrySetResult();
|
||||
}
|
||||
return timer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Completes once code under test has created a timer since the last call.</summary>
|
||||
public Task NextTimerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Task created;
|
||||
lock(_gate)
|
||||
{
|
||||
created = _created.Task;
|
||||
if(created.IsCompleted)
|
||||
{
|
||||
_created = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
return created.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Fires every timer created so far that has not been disposed, as if each deadline had passed.</summary>
|
||||
public void Elapse()
|
||||
{
|
||||
ManualTimer[] pending;
|
||||
lock(_gate)
|
||||
{
|
||||
pending = _timers.ToArray();
|
||||
_timers.Clear();
|
||||
}
|
||||
foreach(ManualTimer timer in pending)
|
||||
{
|
||||
timer.Fire();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A timer that runs its callback once, on request, unless it was disposed first.</summary>
|
||||
private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs the callback unless the owner has already let go of the timer.</summary>
|
||||
public void Fire()
|
||||
{
|
||||
if(Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
callback(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rescheduling is accepted and ignored: only <see cref="Elapse"/> fires a timer.</summary>
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks the timer dead so a later <see cref="Elapse"/> skips it.</summary>
|
||||
public void Dispose() => Interlocked.Exchange(ref _disposed, 1);
|
||||
|
||||
|
||||
|
||||
/// <summary>Asynchronous disposal has nothing to wait for.</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,15 +124,12 @@ public sealed class ReaderThemeTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Null input is refused, and a theme is kept up to the stored size limit exactly and refused one character past it.</summary>
|
||||
/// <summary>Null and oversized input are rejected before parser work.</summary>
|
||||
[Fact]
|
||||
public void Parse_OutsideSizeContract_RejectsInput()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => ReaderTheme.Parse(null!));
|
||||
string valid = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Equal("Nord", ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters)).Name);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters + 1)));
|
||||
Assert.Equal(8 * 128 * 1024, ReaderTheme.MaximumStoredCharacters);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(new string(' ', 128 * 1024 + 1)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using Ganss.Xss;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The elements, attributes and link schemes authored HTML may keep. The configured policy is
|
||||
/// compared with the lists below as sets, so any change to it has to be made here as well, and
|
||||
/// the rendering tests show the policy doing what the lists say.
|
||||
/// </summary>
|
||||
public sealed class SanitizerAllowlistTests
|
||||
{
|
||||
private static readonly string[] s_allowedTags =
|
||||
[
|
||||
"a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "ins", "kbd", "li", "mark", "ol", "p", "pre",
|
||||
"s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
||||
];
|
||||
|
||||
private static readonly string[] s_otherTags =
|
||||
[
|
||||
"acronym", "address", "applet", "area", "article", "aside", "audio", "base", "basefont", "bdi", "bdo", "bgsound", "big",
|
||||
"blink", "button", "canvas", "center", "cite", "data", "datalist", "dfn", "dialog", "dir", "embed", "fencedframe",
|
||||
"fieldset", "figcaption", "figure", "font", "footer", "form", "header", "hgroup", "iframe", "keygen", "label", "legend",
|
||||
"link", "listing", "main", "map", "marquee", "math", "menu", "menuitem", "meta", "meter", "nav", "nobr", "noembed",
|
||||
"noframes", "noscript", "object", "optgroup", "option", "output", "param", "picture", "plaintext", "portal", "progress",
|
||||
"q", "rb", "rp", "rt", "rtc", "ruby", "samp", "script", "search", "section", "select", "selectedcontent", "slot", "source",
|
||||
"strike", "style", "svg", "template", "textarea", "time", "title", "track", "tt", "u", "var", "video", "wbr", "xmp",
|
||||
"custom-element",
|
||||
];
|
||||
|
||||
private static readonly string[] s_allowedAttributes =
|
||||
[
|
||||
"align", "alt", "checked", "class", "colspan", "disabled", "height", "href", "rowspan", "src", "title", "type", "width",
|
||||
];
|
||||
|
||||
private static readonly string[] s_otherAttributes =
|
||||
[
|
||||
"accesskey", "action", "allow", "as", "autocapitalize", "autofocus", "autoplay", "background", "bgcolor", "border",
|
||||
"cellpadding", "cellspacing", "charset", "cite", "color", "content", "contenteditable", "controls", "coords",
|
||||
"crossorigin", "data", "data-mv-body", "data-mv-source-fixed", "data-x", "datetime", "dir", "download", "draggable",
|
||||
"enctype", "enterkeyhint", "face", "for", "form", "formaction", "formmethod", "formtarget", "headers", "hidden",
|
||||
"http-equiv", "id", "inert", "inputmode", "integrity", "is", "ismap", "itemprop", "label", "lang", "list", "loading",
|
||||
"longdesc", "loop", "lowsrc", "manifest", "max", "maxlength", "media", "method", "min", "multiple", "muted", "name",
|
||||
"nonce", "novalidate", "nowrap", "onclick", "onerror", "onfocus", "onload", "onmouseover", "onpointerdown", "ontoggle",
|
||||
"onwheel", "open", "part", "pattern", "ping", "placeholder", "popover", "popovertarget", "poster", "preload", "readonly",
|
||||
"referrerpolicy", "rel", "required", "reversed", "role", "sandbox", "scope", "selected", "shape", "size", "sizes", "slot",
|
||||
"span", "spellcheck", "srcdoc", "srclang", "srcset", "start", "step", "tabindex", "target", "translate", "usemap",
|
||||
"valign", "value", "wrap", "xlink:href", "xml:lang", "xmlns", "aria-label",
|
||||
];
|
||||
|
||||
|
||||
|
||||
/// <summary>The policy the renderer configures is exactly these elements, attributes and schemes, with no styling or data attributes.</summary>
|
||||
[Fact]
|
||||
public void Constructor_ConfiguredPolicy_AllowsExactlyTheListedMarkup()
|
||||
{
|
||||
HtmlSanitizer policy = new MarkdownRenderer().Policy;
|
||||
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), policy.AllowedTags.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Equal(s_allowedAttributes.Order(StringComparer.Ordinal), policy.AllowedAttributes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Equal(["http", "https", "mailto"], policy.AllowedSchemes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Empty(policy.AllowedCssProperties);
|
||||
Assert.Empty(policy.AllowedAtRules);
|
||||
Assert.False(policy.AllowDataAttributes);
|
||||
Assert.False(policy.AllowCssCustomProperties);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Every allowed element survives in a document that nests each one where HTML permits it.</summary>
|
||||
[Fact]
|
||||
public void Render_EveryAllowedElement_SurvivesSanitation()
|
||||
{
|
||||
const string Markup = """
|
||||
<div><p><a href="x.md">a</a><abbr>b</abbr><b>c</b><br><code>d</code><del>e</del><em>f</em><i>g</i><img src="i.png" alt="h"><input type="checkbox"><ins>j</ins><kbd>k</kbd><mark>l</mark><s>m</s><small>n</small><span>o</span><strong>p</strong><sub>q</sub><sup>r</sup></p><blockquote>s</blockquote><hr><pre>t</pre><h1>1</h1><h2>2</h2><h3>3</h3><h4>4</h4><h5>5</h5><h6>6</h6><dl><dt>u</dt><dd>v</dd></dl><details><summary>w</summary>x</details><ul><li>y</li></ul><ol><li>z</li></ol><table><caption>c</caption><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table></div>
|
||||
""";
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
|
||||
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), ElementNames(document), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>No other element survives between allowed elements; its text may remain but the element does not.</summary>
|
||||
[Fact]
|
||||
public void Render_EveryOtherElement_IsRemoved()
|
||||
{
|
||||
var renderer = new MarkdownRenderer();
|
||||
Assert.All(s_otherTags, tag =>
|
||||
{
|
||||
using IHtmlDocument document = Parse(renderer.Render($"<div><{tag} title=\"t\">inner</{tag}><span>after</span></div>"));
|
||||
Assert.Empty(ElementNames(document).Except(["div", "span"], StringComparer.Ordinal));
|
||||
});
|
||||
Assert.Empty(s_allowedTags.Intersect(s_otherTags, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Table parts that only exist inside a table, and are not allowed, are removed there while the allowed parts stay.</summary>
|
||||
[Fact]
|
||||
public void Render_TablePartsOutsideTheAllowlist_AreRemovedInsideATable()
|
||||
{
|
||||
const string Markup = "<table><colgroup><col span=\"2\"></colgroup><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>";
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
|
||||
Assert.Empty(document.Body!.QuerySelectorAll("colgroup,col,tfoot"));
|
||||
Assert.NotNull(document.Body.QuerySelector("table > thead > tr > th"));
|
||||
Assert.NotNull(document.Body.QuerySelector("table > tbody > tr > td"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Exactly the allowed attributes survive on an allowed element; identity, behaviour, styling and data attributes do not.</summary>
|
||||
[Fact]
|
||||
public void Render_EveryAttribute_KeepsExactlyTheAllowedOnes()
|
||||
{
|
||||
string attributes = string.Join(' ', s_allowedAttributes.Concat(s_otherAttributes).Select(name => string.Equals(name, "class", StringComparison.Ordinal) ? "class=\"frontmatter\"" : name + "=\"1\"")) + " style=\"color:red\"";
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<div><span {attributes}>text</span></div>"));
|
||||
IElement span = Assert.Single(document.Body!.QuerySelectorAll("span"));
|
||||
Assert.Equal(s_allowedAttributes, span.Attributes.Select(attribute => attribute.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Empty(s_allowedAttributes.Intersect(s_otherAttributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the fixed styling classes and bounded code-language classes survive, spelled exactly.</summary>
|
||||
[Theory]
|
||||
[InlineData("frontmatter fm-key fm-value task-list task-list-item", "frontmatter fm-key fm-value task-list task-list-item")]
|
||||
[InlineData("language-csharp hljs keyword", "language-csharp")]
|
||||
[InlineData("LANGUAGE-csharp Language-go language-rust", "language-rust")]
|
||||
[InlineData("Frontmatter FM-KEY", null)]
|
||||
[InlineData("language- language-a;b language-<x>", null)]
|
||||
public void Render_ClassNames_KeepsOnlyTheStylingClasses(string authored, string? kept)
|
||||
{
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<span class=\"{authored}\">text</span>"));
|
||||
Assert.Equal(kept, Assert.Single(document.Body!.QuerySelectorAll("span")).GetAttribute("class"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A code-language class is kept up to 80 characters and dropped one character past that.</summary>
|
||||
[Fact]
|
||||
public void Render_CodeLanguageClassAtAndPastTheLengthLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = "language-" + new string('x', 71);
|
||||
Assert.Equal(80, atLimit.Length);
|
||||
using IHtmlDocument kept = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}\">text</span>"));
|
||||
Assert.Equal(atLimit, Assert.Single(kept.Body!.QuerySelectorAll("span")).GetAttribute("class"));
|
||||
using IHtmlDocument dropped = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}x\">text</span>"));
|
||||
Assert.Null(Assert.Single(dropped.Body!.QuerySelectorAll("span")).GetAttribute("class"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The attribute that carries source positions is allowed only during a source-aware render, whether that render finishes or fails.</summary>
|
||||
[Fact]
|
||||
public void RenderWithSources_AfterTheRender_NoLongerAllowsTheSourceAttribute()
|
||||
{
|
||||
var renderer = new MarkdownRenderer();
|
||||
var sources = new DocumentSourceMap("# Title");
|
||||
Assert.Contains("Title", renderer.RenderWithSources("# Title", FrontmatterMode.Hide, sources), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => renderer.RenderWithSources("# Title", (FrontmatterMode)99, sources));
|
||||
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only web and mail links keep a target; every other scheme, including ones Windows would hand to another application, is removed.</summary>
|
||||
[Theory]
|
||||
[InlineData("http://example.com/a", true)]
|
||||
[InlineData("https://example.com/a", true)]
|
||||
[InlineData("mailto:someone@example.com", true)]
|
||||
[InlineData("child.md", true)]
|
||||
[InlineData("#section", true)]
|
||||
[InlineData("HTTPS://example.com/a", true)]
|
||||
[InlineData("ftp://example.com/a", false)]
|
||||
[InlineData("file:///C:/private.txt", false)]
|
||||
[InlineData("FILE:///C:/private.txt", false)]
|
||||
[InlineData("javascript:run()", false)]
|
||||
[InlineData("vbscript:run()", false)]
|
||||
[InlineData("data:text/html,<p>x</p>", false)]
|
||||
[InlineData("blob:https://example.com/1", false)]
|
||||
[InlineData("about:blank", false)]
|
||||
[InlineData("ws://example.com", false)]
|
||||
[InlineData("tel:5551234", false)]
|
||||
[InlineData("sms:5551234", false)]
|
||||
[InlineData("ms-settings:defaultapps", false)]
|
||||
[InlineData("ms-msdt:/id", false)]
|
||||
[InlineData("ms-word:ofe|u|https://example.com/a.docx", false)]
|
||||
[InlineData("search-ms:query=x", false)]
|
||||
[InlineData("shell:startup", false)]
|
||||
[InlineData("res://ieframe.dll/x", false)]
|
||||
[InlineData("view-source:https://example.com", false)]
|
||||
[InlineData("onenote:x", false)]
|
||||
public void Render_LinkScheme_KeepsOnlyWebAndMailTargets(string target, bool kept)
|
||||
{
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<a href=\"{target}\">link</a>"));
|
||||
IElement link = Assert.Single(document.Body!.QuerySelectorAll("a"));
|
||||
Assert.Equal(kept, link.HasAttribute("href"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The distinct element names in the sanitized body, in ordinal order.</summary>
|
||||
private static string[] ElementNames(IHtmlDocument document)
|
||||
=> document.Body!.QuerySelectorAll("*").Select(element => element.LocalName).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
|
||||
|
||||
|
||||
/// <summary>Parses renderer output the way the browser surface does.</summary>
|
||||
private static IHtmlDocument Parse(string html) => new HtmlParser().ParseDocument("<!doctype html><html><body>" + html + "</body></html>");
|
||||
}
|
||||
@@ -35,10 +35,6 @@ public sealed class SecurityFuzzTests
|
||||
"[section](#heading) \n", "| A | B |\n|---|---|\n| x | y |\n",
|
||||
"- [x] Task\n> Quote\n", "😀 café\0\t\r\n", "<!-- <code> -->", "</code></pre></div>",
|
||||
"</style><script>execute()</script>", "<script>literal</script>", "\"'<>:=[]()\\/!?%",
|
||||
"<video src='x.mp4' autoplay onplay='execute()'><source src='y.mp4'></video><audio src='x.mp3' controls></audio>",
|
||||
"<embed src='x.swf'><object data='x.swf'><param name='a' value='b'></object><math><mi xlink:href='javascript:execute()'>x</mi></math>",
|
||||
"<link rel='stylesheet' href='https://outside.invalid/x.css'><meta http-equiv='refresh' content='0;url=https://outside.invalid'><base href='https://outside.invalid/'>",
|
||||
"<textarea autofocus onfocus='execute()'>text</textarea><select><option>one</option></select><a href='x.md' target='_blank' name='n' ping='https://outside.invalid'>x</a>",
|
||||
];
|
||||
|
||||
|
||||
@@ -246,12 +242,11 @@ public sealed class SecurityFuzzTests
|
||||
/// <summary>Uses the parsed DOM's executable capabilities as the oracle, not the renderer's private allowlist.</summary>
|
||||
private static void AssertPassive(IHtmlDocument document)
|
||||
{
|
||||
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta,link,audio,video,source,param,textarea,select,option"));
|
||||
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta[http-equiv],audio,video"));
|
||||
foreach(IElement element in document.QuerySelectorAll("*"))
|
||||
{
|
||||
Assert.DoesNotContain(element.Attributes, attribute => attribute.Name.StartsWith("on", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(element.HasAttribute("srcdoc") || element.HasAttribute("srcset") || element.HasAttribute("style"));
|
||||
Assert.False(element.HasAttribute("target") || element.HasAttribute("ping") || element.HasAttribute("name") || element.HasAttribute("autofocus"));
|
||||
foreach(string name in new[] { "href", "src" })
|
||||
{
|
||||
string? value = element.GetAttribute(name);
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
using MarkdownViewr.Windows.Core.Editors;
|
||||
using MarkdownViewr.Windows.Core.Printing;
|
||||
using MarkdownViewr.Windows.Core.Themes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation of text that comes from a document, a settings file, an imported theme or the
|
||||
/// browser surface: each check is pinned by an input that only that check rejects.
|
||||
/// </summary>
|
||||
public sealed class UntrustedInputValidationTests
|
||||
{
|
||||
/// <summary>Metadata keys and values are display text: markup in either is escaped, never parsed.</summary>
|
||||
[Fact]
|
||||
public void RenderMetadata_MarkupInKeyAndValue_IsEscapedText()
|
||||
{
|
||||
const string Key = "<b>key</b>";
|
||||
const string Value = "<img src=x onerror=run()> & \"quoted\"";
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(Key + ": " + Value));
|
||||
Assert.Equal(Key, Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
|
||||
Assert.Equal(Value, Assert.Single(parsed.QuerySelectorAll("td.fm-value")).TextContent);
|
||||
Assert.Empty(parsed.QuerySelectorAll("b,img"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A line that starts with its colon still has a key cell, so the table keeps two columns.</summary>
|
||||
[Fact]
|
||||
public void RenderMetadata_LeadingColon_KeepsAnEmptyKeyCell()
|
||||
{
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(": value"));
|
||||
Assert.Equal("", Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
|
||||
IElement value = Assert.Single(parsed.QuerySelectorAll("td.fm-value"));
|
||||
Assert.Equal("value", value.TextContent);
|
||||
Assert.False(value.HasAttribute("colspan"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The private source marker is new for every render, so an authored attribute cannot predict it.</summary>
|
||||
[Fact]
|
||||
public void AttributeName_EachSourceMap_IsUnpredictable()
|
||||
{
|
||||
string first = new DocumentSourceMap("# Title").AttributeName;
|
||||
string second = new DocumentSourceMap("# Title").AttributeName;
|
||||
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", first);
|
||||
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", second);
|
||||
Assert.NotEqual(first, second, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Raw HTML headings map to their source at every level, including the sixth.</summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
public void SourceHeadings_RawHtmlHeadingLevel_MapsItsSource(int level)
|
||||
{
|
||||
string heading = $"<h{level}>Raw</h{level}>";
|
||||
string source = "Intro\n\n" + heading + "\n\nAfter\n";
|
||||
var outline = new DocumentOutline(source);
|
||||
Assert.Equal(level, Assert.Single(outline.Headings).Level);
|
||||
DocumentSourceHeading mapped = Assert.Single(outline.SourceHeadings);
|
||||
Assert.Equal(heading, source[mapped.Start..mapped.End]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A level the HTML standard does not define is not a heading and gets no source mapping.</summary>
|
||||
[Fact]
|
||||
public void SourceHeadings_RawHtmlLevelSeven_IsNotAHeading()
|
||||
{
|
||||
var outline = new DocumentOutline("<h7>Not a heading</h7>\n");
|
||||
Assert.Empty(outline.Headings);
|
||||
Assert.Empty(outline.SourceHeadings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Loading repairs every stored list and object, not only the ones the window happens to read first.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_CorruptPrintEditorsAndThemeLists_RepairsEachProperty()
|
||||
{
|
||||
var valid = new EditorConfiguration(Guid.NewGuid(), "Editor", @"C:\Editor\editor.exe");
|
||||
var settings = new AppSettings
|
||||
{
|
||||
Print = new PrintOptions(Theme: "Bad\u0001Theme", PaddingPixels: 5000),
|
||||
ConfiguredEditors = [valid, new EditorConfiguration(Guid.NewGuid(), "Script", @"C:\Editor\run.cmd"), new EditorConfiguration(Guid.Empty, "Empty", @"C:\Editor\other.exe")],
|
||||
DisabledThemes = ["Nord", "Nord", "Bad\u0001Name", ""],
|
||||
DeletedBuiltInThemes = ["Dracula", "Dracula", "Bad\u0002Name"],
|
||||
ThemeOrder = ["Nord", "Nord", "Bad\u0003Name"],
|
||||
DocumentTheme = "Bad\u0001Theme",
|
||||
};
|
||||
settings.Sanitize();
|
||||
Assert.Equal("Clean Printing", settings.Print.Theme);
|
||||
Assert.Equal(96, settings.Print.PaddingPixels);
|
||||
Assert.Equal([valid], settings.ConfiguredEditors);
|
||||
Assert.Equal(valid.Executable, settings.ExternalEditor);
|
||||
Assert.Equal(["Nord"], settings.DisabledThemes);
|
||||
Assert.Equal(["Dracula"], settings.DeletedBuiltInThemes);
|
||||
Assert.Equal(["Nord"], settings.ThemeOrder);
|
||||
Assert.Equal("Catppuccin Mocha", settings.DocumentTheme);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A missing print object, as an older file or a hand edit leaves it, becomes the defaults.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_MissingPrintObject_UsesDefaults()
|
||||
{
|
||||
var settings = new AppSettings { Print = null! };
|
||||
settings.Sanitize();
|
||||
Assert.Equal(new PrintOptions(), settings.Print);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Editing a theme cannot silently raise it to a newer schema; that needs the import confirmation.</summary>
|
||||
[Fact]
|
||||
public void SaveEdited_SchemaVersionRaised_RequiresConfirmation()
|
||||
{
|
||||
ReaderTheme original = ThemeCatalog.Find(null);
|
||||
JsonNode raised = JsonNode.Parse(original.Json)!;
|
||||
raised["schemaVersion"] = 2;
|
||||
var edited = ReaderTheme.ParseCompatible(raised.ToJsonString());
|
||||
Assert.Equal(1, original.SchemaVersion);
|
||||
Assert.Equal(2, edited.SchemaVersion);
|
||||
Assert.Throws<InvalidOperationException>(() => ThemeManagement.SaveEdited(new AppSettings(), original, edited));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An already confirmed newer-schema theme stays editable at its own version.</summary>
|
||||
[Fact]
|
||||
public void SaveEdited_SameNewerSchema_IsSaved()
|
||||
{
|
||||
JsonNode newer = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
|
||||
newer["schemaVersion"] = 2;
|
||||
newer["name"] = "Owned future";
|
||||
var theme = ReaderTheme.ParseCompatible(newer.ToJsonString());
|
||||
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme, approveNewerSchema: true);
|
||||
AppSettings saved = ThemeManagement.SaveEdited(imported, theme, theme);
|
||||
Assert.Equal("Owned future", saved.DocumentTheme);
|
||||
Assert.Equal(2, ThemeManagement.Active(saved).SchemaVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A copy of a theme whose name is already at the limit still has a name the theme parser accepts.</summary>
|
||||
[Fact]
|
||||
public void CopyTheme_LongestNameTakenTwice_StaysWithinTheNameLimit()
|
||||
{
|
||||
ReaderTheme longest = ThemeCatalog.Find(null).Rename(new string('N', 80));
|
||||
AppSettings settings = ThemeManagement.Import(new AppSettings(), longest);
|
||||
ReaderTheme firstCopy = ThemeManagement.CopyTheme(settings, longest);
|
||||
settings = ThemeManagement.Import(settings, firstCopy);
|
||||
ReaderTheme secondCopy = ThemeManagement.CopyTheme(settings, longest);
|
||||
Assert.Equal(80, firstCopy.Name.Length);
|
||||
Assert.Equal(80, secondCopy.Name.Length);
|
||||
Assert.EndsWith(" (2)", secondCopy.Name, StringComparison.Ordinal);
|
||||
Assert.Equal(secondCopy.Name, ReaderTheme.ParseCompatible(secondCopy.Json).Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Editor and document paths are accepted up to the Windows limit and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void IsExecutablePath_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = @"C:\" + new string('e', 32767 - 7) + ".exe";
|
||||
string pastLimit = @"C:\" + new string('e', 32768 - 7) + ".exe";
|
||||
Assert.Equal(32767, atLimit.Length);
|
||||
Assert.True(EditorLaunch.IsExecutablePath(atLimit));
|
||||
Assert.False(EditorLaunch.IsExecutablePath(pastLimit));
|
||||
string document = @"C:\" + new string('d', 32767 - 6) + ".md";
|
||||
Assert.Equal(32767, document.Length);
|
||||
Assert.Equal(document, Assert.Single(EditorLaunch.Create(@"C:\Editor\editor.exe", document).ArgumentList));
|
||||
Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", document + "x"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Recent paths are kept up to the Windows limit and dropped one character past it.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_RecentPathAtAndPastTheWindowsLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = @"C:\" + new string('d', 32767 - 6) + ".md";
|
||||
Assert.Equal([atLimit], RecentDocuments.Sanitize([atLimit, atLimit + "x"]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A find term is accepted at its limit and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void Constructor_FindTermAtAndPastTheLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = new('q', DocumentFind.MaximumQueryCharacters);
|
||||
Assert.Equal(atLimit, new DocumentFind(atLimit + " tail", atLimit).Query);
|
||||
Assert.Throws<ArgumentException>(() => new DocumentFind("source", atLimit + "q"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The viewport result is read up to its size cap and ignored past it, even when it would parse.</summary>
|
||||
[Fact]
|
||||
public void FromBrowserResult_ViewportAtAndPastTheSizeCap_IsBoundedExactly()
|
||||
{
|
||||
string atCap = "[0.25," + new string(' ', 128 - 11) + "0.75]";
|
||||
Assert.Equal(128, atCap.Length);
|
||||
Assert.Equal(0.75, DocumentViewport.FromBrowserResult(atCap).VerticalFraction);
|
||||
Assert.Equal(default, DocumentViewport.FromBrowserResult(atCap.Replace("[", "[ ", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The scroll-state result is read up to its size cap for the heading count and ignored past it.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
public void FromBrowserResult_ScrollStateAtAndPastTheSizeCap_IsBoundedExactly(int headings)
|
||||
{
|
||||
int cap = headings * 128 + 512;
|
||||
string atCap = "[1,2,[],[]" + new string(' ', cap - 11) + "]";
|
||||
Assert.Equal(cap, atCap.Length);
|
||||
Assert.NotNull(DocumentScrollState.FromBrowserResult(atCap, headings));
|
||||
Assert.Null(DocumentScrollState.FromBrowserResult(atCap.Replace("[1,", "[ 1,", StringComparison.Ordinal), headings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The scroll-state result has exactly four parts; a longer array is not the application's own query result.</summary>
|
||||
[Theory]
|
||||
[InlineData("[1,2,[]]")]
|
||||
[InlineData("[1,2,[],[],[]]")]
|
||||
[InlineData("[1,2,[],[],0]")]
|
||||
public void FromBrowserResult_ScrollStateWithWrongNumberOfParts_IsRejected(string json)
|
||||
=> Assert.Null(DocumentScrollState.FromBrowserResult(json, 2));
|
||||
|
||||
|
||||
|
||||
/// <summary>Print styles are built from repaired options, so a corrupt padding value cannot reach the stylesheet.</summary>
|
||||
[Theory]
|
||||
[InlineData(double.NaN, "padding:0px")]
|
||||
[InlineData(double.PositiveInfinity, "padding:0px")]
|
||||
[InlineData(5000, "padding:96px")]
|
||||
[InlineData(-3, "padding:0px")]
|
||||
public void Build_CorruptPadding_UsesTheRepairedValue(double padding, string expected)
|
||||
{
|
||||
ReaderTheme theme = ThemeCatalog.Find(null);
|
||||
string css = PrintStyles.Build(new PrintOptions(PaddingPixels: padding), theme, [theme], 1);
|
||||
Assert.Contains(expected, css, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("NaN", css, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Infinity", css, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("∞", css, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Text the JSON reader cannot decode, an unpaired surrogate escape, makes the theme invalid
|
||||
/// wherever it appears: in a value the theme reads, in a property name, or in a value this
|
||||
/// version ignores but would later re-serialize. So does a repeated property name.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"\\uD83D\"")]
|
||||
[InlineData("\"background\": \"#2e3440\"", "\"background\": \"\\uD800\"")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"A\\uDC00B\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"\\uD83D\": 1")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"\\uD83D\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [1, [\"ok\", {\"deep\": \"\\uDFFF\"}]]")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"\\uD800x\": \"y\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"name\": \"Nord\"")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"body\": \"Other\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"extra\": {\"a\": 1, \"a\": 2}")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"na\\u006de\": \"Nord\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [\"\\uD83D\"]")]
|
||||
public void Parse_UndecodableTextOrRepeatedProperty_IsRejectedAsAnInvalidTheme(string valid, string invalid)
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Contains(valid, original, StringComparison.Ordinal);
|
||||
string json = original.Replace(valid, invalid, StringComparison.Ordinal);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
|
||||
var settings = new AppSettings { UserThemes = [json, original] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([original], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A theme that parses can be renamed, copied, edited and imported under a new name with its
|
||||
/// extra properties intact: those operations re-serialize the whole document. Names that differ
|
||||
/// only in case, and the same name in two different objects, are not repeats.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_AcceptedThemeWithExtraProperties_SurvivesRenameCopyEditAndImport()
|
||||
{
|
||||
string json = ThemeCatalog.Find("Nord").Json.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"Name\": \"other case\", \"note\": \"caf\\u00e9 \\ud83d\\ude00\", \"future\": {\"name\": \"inner\", \"list\": [1, \"two\", null]}", StringComparison.Ordinal);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
Assert.Equal("Renamed", theme.Rename("Renamed").Name);
|
||||
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme);
|
||||
Assert.Contains(ThemeManagement.All(imported), item => item.Name.StartsWith("Nord (", StringComparison.Ordinal));
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(imported, theme).Name, StringComparison.Ordinal);
|
||||
ReaderTheme edited = theme.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
|
||||
Assert.Equal("Edited", edited.Name);
|
||||
Assert.Contains("caf", edited.Json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"Name\":\"other case\"", edited.Json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"name\":\"inner\"", edited.Json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renaming, copying and editing write characters as themselves, so a theme near the size
|
||||
/// limit whose text is full of quote marks, angle brackets or non-ASCII characters stays
|
||||
/// within the limit instead of growing sixfold and being refused.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a>b+c{font:'x'}")]
|
||||
[InlineData("/* \u6f22\u5b57 caf\u00e9 & <tag> */")]
|
||||
public void Rename_ThemeNearTheSizeLimit_DoesNotGrowPastIt(string fragment)
|
||||
{
|
||||
string css = string.Concat(Enumerable.Repeat(fragment, 60_000 / fragment.Length));
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Contains("\"name\": \"Nord\"", original, StringComparison.Ordinal);
|
||||
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"" + css + "\"", StringComparison.Ordinal);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
ReaderTheme renamed = theme.Rename("Renamed");
|
||||
Assert.True(renamed.Json.Length <= json.Length);
|
||||
Assert.Contains(fragment, renamed.Json, StringComparison.Ordinal);
|
||||
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
|
||||
Assert.True(edited.Json.Length <= json.Length);
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), theme).Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// JSON writes emoji, no-break and ideographic spaces and private-use glyphs as six-character
|
||||
/// escapes whatever the encoder. A theme as large as an import may be, made of nothing else,
|
||||
/// can still be renamed, copied and edited: the stored limit leaves room for the growth.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("😀")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("")]
|
||||
public void Rename_ThemeAtTheImportLimitMadeOfEscapedCharacters_IsKept(string character)
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
string opening = "\"name\": \"Nord\", \"note\": \"";
|
||||
int room = ReaderTheme.MaximumCharacters - original.Length - (opening.Length - "\"name\": \"Nord\"".Length) - 1;
|
||||
string filler = string.Concat(Enumerable.Repeat(character, room / character.Length)).PadRight(room, 'x');
|
||||
string json = original.Replace("\"name\": \"Nord\"", opening + filler + "\"", StringComparison.Ordinal);
|
||||
Assert.Equal(ReaderTheme.MaximumCharacters, json.Length);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
ReaderTheme renamed = theme.Rename("A considerably longer name than the one it had");
|
||||
Assert.InRange(renamed.Json.Length, json.Length + 1, ReaderTheme.MaximumStoredCharacters);
|
||||
Assert.Equal(renamed.Json.Length, renamed.Rename(renamed.Name).Json.Length);
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), renamed).Name, StringComparison.Ordinal);
|
||||
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "body { color: red }");
|
||||
Assert.Equal("Edited", edited.Name);
|
||||
var settings = new AppSettings { UserThemes = [edited.Json] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([edited.Json], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A string that is not well-formed UTF-16 is refused as an invalid theme, and a stored one is dropped, rather than failing as a programming error.</summary>
|
||||
[Fact]
|
||||
public void Parse_TextWithAnUnpairedSurrogate_IsRejectedAsAnInvalidTheme()
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"No\uD83Drd\"", StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"name\": \"Nord\"", json, StringComparison.Ordinal);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
|
||||
var settings = new AppSettings { UserThemes = [json, original] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([original], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the exact lower-case channel name selects the development identity.</summary>
|
||||
[Theory]
|
||||
[InlineData("DEV")]
|
||||
[InlineData("Dev")]
|
||||
[InlineData("dev ")]
|
||||
[InlineData("development")]
|
||||
public void ForChannel_OtherSpellingOfDev_KeepsTheStableIdentity(string channel)
|
||||
=> Assert.Equal("markdownviewr-windows", AppIdentity.ForChannel(channel).Id);
|
||||
|
||||
|
||||
|
||||
/// <summary>A colour that is not six hexadecimal digits after a hash, or a label containing a control character, is refused.</summary>
|
||||
[Theory]
|
||||
[InlineData("colors.background", "\";ffffff\"")]
|
||||
[InlineData("colors.background", "\"#fffffff\"")]
|
||||
[InlineData("colors.background", "\"#ffffffff\"")]
|
||||
[InlineData("colors.background", "\"#fffff\"")]
|
||||
[InlineData("colors.background", "\"ffffff#\"")]
|
||||
[InlineData("colors.text", "\"#12345g\"")]
|
||||
[InlineData("name", "\"Theme\\u0001Name\"")]
|
||||
[InlineData("name", "\"Theme\\u007fName\"")]
|
||||
[InlineData("fonts.heading", "\"Font\\u0001Name\"")]
|
||||
[InlineData("fonts.code", "\"Font\\u009bName\"")]
|
||||
public void Parse_MalformedColorOrLabel_IsRejected(string path, string json)
|
||||
{
|
||||
JsonNode root = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
|
||||
string[] parts = path.Split('.');
|
||||
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
|
||||
parent[parts[^1]] = JsonNode.Parse(json);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
using MarkdownViewr.Windows.Core.Themes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>The written-out Windows name rules, and the two places that depend on them.</summary>
|
||||
public sealed class WindowsFileNamesTests
|
||||
{
|
||||
private static readonly string[] s_devices =
|
||||
[
|
||||
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
|
||||
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
|
||||
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
|
||||
];
|
||||
|
||||
|
||||
|
||||
/// <summary>A device is a device in any letter case, with any extension, and with spaces before the extension or at the end.</summary>
|
||||
[Fact]
|
||||
public void IsReservedDeviceName_EveryDeviceSpelling_IsReserved()
|
||||
{
|
||||
Assert.All(Spellings(), spelling =>
|
||||
{
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".png"), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".tar.md"), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " .png"), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " "), spelling);
|
||||
});
|
||||
Assert.Equal(96, Spellings().Count());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Names that only resemble a device stay ordinary file names; only spaces are ignored before the extension.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("console.md")]
|
||||
[InlineData("COM10.png")]
|
||||
[InlineData("COM")]
|
||||
[InlineData("LPT.txt")]
|
||||
[InlineData("xCON.png")]
|
||||
[InlineData("CON-notes.md")]
|
||||
[InlineData("CON-.md")]
|
||||
[InlineData("null.md")]
|
||||
[InlineData(" NUL.png")]
|
||||
[InlineData("NUL\u00A0.png")]
|
||||
[InlineData("NUL\t.png")]
|
||||
[InlineData("readme.NUL")]
|
||||
[InlineData("COM⁴.png")]
|
||||
[InlineData("CLOCK$")]
|
||||
[InlineData("CLOCK$.txt")]
|
||||
public void IsReservedDeviceName_OrdinaryName_IsNotReserved(string name) => Assert.False(WindowsFileNames.IsReservedDeviceName(name));
|
||||
|
||||
|
||||
|
||||
/// <summary>A missing name is a contract error, not an ordinary file.</summary>
|
||||
[Fact]
|
||||
public void IsReservedDeviceName_Null_RejectsInput() => Assert.Throws<ArgumentNullException>(() => WindowsFileNames.IsReservedDeviceName(null!));
|
||||
|
||||
|
||||
|
||||
/// <summary>The printable characters Windows forbids in a name; control characters are refused separately by each caller.</summary>
|
||||
[Fact]
|
||||
public void InvalidCharacters_Constant_ListsThePrintableForbiddenCharacters() => Assert.Equal("\"*/:<>?\\|", new string(WindowsFileNames.InvalidCharacters.Order().ToArray()));
|
||||
|
||||
|
||||
|
||||
/// <summary>No image or linked-document request can name a device in any spelling, as the file or as a folder on the way to it.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_DeviceSegment_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.All(Spellings(), spelling =>
|
||||
{
|
||||
string segment = Uri.EscapeDataString(spelling);
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + ".png")));
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "%20.png")));
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "/picture.png")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".md")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".tar.md")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/" + segment + "/child.md")));
|
||||
});
|
||||
Assert.Equal("console.png", policy.GetImageRelativePath(new Uri(policy.DocumentUri, "console.png")));
|
||||
Assert.Equal("docs/com10/child.md", policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/com10/child.md")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A theme named after a device, in any case, never becomes the suggested export file name.</summary>
|
||||
[Fact]
|
||||
public void SuggestedFileName_EveryDeviceName_IsPrefixed()
|
||||
{
|
||||
Assert.All(s_devices, device =>
|
||||
{
|
||||
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device)));
|
||||
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device.ToLowerInvariant())));
|
||||
Assert.Equal("theme-" + device.ToLowerInvariant() + ".backup.json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device + ".backup")));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Leading and trailing dots and hyphens are dropped from a suggested name, and a name made only of them falls back to a plain one.</summary>
|
||||
[Theory]
|
||||
[InlineData("-Edge-", "edge.json")]
|
||||
[InlineData(".Dotted.", "dotted.json")]
|
||||
[InlineData("--", "theme.json")]
|
||||
[InlineData(" spaced ", "spaced.json")]
|
||||
[InlineData("-con-", "theme-con.json")]
|
||||
public void SuggestedFileName_LeadingAndTrailingSeparators_AreTrimmed(string name, string expected)
|
||||
=> Assert.Equal(expected, ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(name)));
|
||||
|
||||
|
||||
|
||||
/// <summary>Each device name in upper, lower and mixed case.</summary>
|
||||
private static IEnumerable<string> Spellings()
|
||||
=> s_devices.SelectMany(device => new[] { device, device.ToLowerInvariant(), char.ToUpperInvariant(device[0]) + device[1..].ToLowerInvariant() });
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <summary>Real current-user pipes prove bounded native delivery without taking desktop input or launching an app.</summary>
|
||||
public sealed class ActivationInboxTests
|
||||
{
|
||||
/// <summary>A startup request survives until a reader dispatcher attaches; rejected dispatch returns a negative acknowledgement.</summary>
|
||||
[Fact]
|
||||
public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Empty(received);
|
||||
inbox.Attach(value => { received.Enqueue(value); return true; });
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
inbox.Attach(_ => false);
|
||||
Assert.False(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Oversized frames, unknown JSON fields and relative pipe paths are rejected, then a real valid peer still connects.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_MalformedPeers_RejectsAndRecovers()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
inbox.Attach(value => { received.Enqueue(value); return true; });
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
await SendMalformed(key, ActivationRequest.MaximumPayloadBytes + 1, [], deadline.Token);
|
||||
byte[] unknown = Encoding.UTF8.GetBytes("{\"Paths\":[],\"Command\":\"execute\"}");
|
||||
await SendMalformed(key, unknown.Length, unknown, deadline.Token);
|
||||
byte[] relative = Encoding.UTF8.GetBytes("{\"Paths\":[\"relative.md\"]}");
|
||||
await SendMalformed(key, relative.Length, relative, deadline.Token);
|
||||
var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
Assert.Equal(3, errors.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A disconnected sender never blocks the next activation and shutdown releases the pipe without leaking an owned connection.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
Task completed;
|
||||
using(var inbox = new ActivationInbox(key, errors.Enqueue))
|
||||
{
|
||||
inbox.Attach(_ => true);
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(deadline.Token);
|
||||
}
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
|
||||
Assert.Single(errors);
|
||||
completed = inbox.Completion;
|
||||
inbox.Dispose();
|
||||
inbox.Dispose();
|
||||
}
|
||||
await completed.WaitAsync(deadline.Token);
|
||||
using var replacement = new ActivationInbox(key, _ => { });
|
||||
replacement.Attach(_ => true);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An already-closed peer must release its native connection before another real activation.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var connectionContext = new DeferredConnectionContext();
|
||||
SynchronizationContext? previous = SynchronizationContext.Current;
|
||||
ActivationInbox inbox;
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(connectionContext);
|
||||
inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(previous);
|
||||
}
|
||||
using ActivationInbox ownedInbox = inbox;
|
||||
inbox.Attach(value => { received.Enqueue(value); return true; });
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(deadline.Token);
|
||||
}
|
||||
(SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(deadline.Token);
|
||||
// Resume the real server after the real peer closes, exercising the synchronous broken-read path.
|
||||
var resume = Task.Run(() => callback(state), deadline.Token);
|
||||
Assert.IsAssignableFrom<IOException>(await reported.Task.WaitAsync(deadline.Token));
|
||||
var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
await resume.WaitAsync(deadline.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Defers only the first real server continuation, making the already-closed native peer schedule reproducible.</summary>
|
||||
private sealed class DeferredConnectionContext : SynchronizationContext
|
||||
{
|
||||
internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>Captures the actual asynchronous continuation without changing pipe bytes, handles or acknowledgements.</summary>
|
||||
public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends an intentionally invalid frame and waits for the actual server to close it before starting the next peer.</summary>
|
||||
private static async Task SendMalformed(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await client.ConnectAsync(cancellationToken);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
|
||||
await client.WriteAsync(length, cancellationToken);
|
||||
await client.WriteAsync(payload, cancellationToken);
|
||||
await client.FlushAsync(cancellationToken);
|
||||
byte[] response = new byte[1];
|
||||
try
|
||||
{
|
||||
Assert.Equal(0, await client.ReadAsync(response, cancellationToken));
|
||||
}
|
||||
catch(IOException)
|
||||
{
|
||||
// Windows may report a broken pipe rather than EOF after the server rejects the malformed frame.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <summary>Sender-relative paths, Unicode, duplicate launches and stable/dev/profile isolation retain their actual meanings.</summary>
|
||||
public sealed class ActivationRequestTests
|
||||
{
|
||||
/// <summary>Canonicalization belongs to the sender, including literal shell characters and case-insensitive duplicate paths.</summary>
|
||||
[Fact]
|
||||
public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "sender space");
|
||||
var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
|
||||
Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
|
||||
Assert.False(request.HasInvalidPath);
|
||||
Assert.Empty(ActivationRequest.Create([], directory).Paths);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Malformed messages cannot open relative paths or allocate an unbounded number of document windows.</summary>
|
||||
[Fact]
|
||||
public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(null!).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["relative.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([""]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\" + new string('a', 32768)]).Validate());
|
||||
Assert.Throws<ArgumentException>(() => ActivationRequest.Create(Enumerable.Repeat("one.md", ActivationRequest.MaximumPaths + 1), Path.GetTempPath()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Equivalent directory spellings share an instance, while distinct review profiles and channel installation roots do not.</summary>
|
||||
[Fact]
|
||||
public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "viewer");
|
||||
string data = Path.Combine(Path.GetTempPath(), "profile");
|
||||
string key = ActivationRequest.InstanceKey(root, data);
|
||||
Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
|
||||
Assert.StartsWith("k22-markdownviewr-", key, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(root, key, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,6 @@
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\DocumentWatcher.cs" Link="WindowsAdapters\DocumentWatcher.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\FileTypeRegistration.cs" Link="WindowsAdapters\FileTypeRegistration.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ShellFileAssociations.cs" Link="WindowsAdapters\ShellFileAssociations.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationRequest.cs" Link="WindowsAdapters\ActivationRequest.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationJsonContext.cs" Link="WindowsAdapters\ActivationJsonContext.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationInbox.cs" Link="WindowsAdapters\ActivationInbox.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user