Compare commits

..
1 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 1c680d7c02 fix(activation): always open the launched document and start without the inbox when it is unavailable
node / node (push) Successful in 24s
build / scripts (push) Successful in 24s
windows / windows (push) Successful in 2m5s
build / windows (push) Successful in 2m5s
The reader created its activation pipe outside any error handling, so it
could not start while the pipe name was held by a reader that had not
finished closing or by another program. It now starts without an inbox,
and releases the pipe before the instance key so the next reader can
create its own.

A launch whose request the running reader does not take (no listener, a
refusal, or a deadline) used to exit with code 1 and no window. It now
opens its documents in its own process. An argument that cannot name a
document is dropped and reported while the usable ones still open.

The request, schema and inbox move to Core/Activation, inside the
coverage gate and the core test run. Deadlines run on a TimeProvider:
a sender waits two seconds for the pipe and each side ten seconds for one
exchange, and the tests reach both without waiting. The listener's
disconnect and Dispose share a lock instead of catching the race between
them.

ADR-0005, the threat model, the fuzzing guide and the GitHub shadow
workflows follow the move.

Closes #52

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 17:51:51 -06:00
54 changed files with 1005 additions and 4103 deletions

No files matched your search

+1 -1
View File
@@ -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
-9
View File
@@ -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
-138
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -2
View File
@@ -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'
```
+9 -2
View File
@@ -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
-2
View File
@@ -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();
}
}
@@ -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);
-106
View File
@@ -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;
+7 -16
View File
@@ -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;
}
+3 -8
View File
@@ -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;
+2 -1
View File
@@ -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);
}
+44 -15
View File
@@ -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;
}
}
@@ -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) ![image](images/hello%20world.png)\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>", "&lt;script&gt;literal&lt;/script&gt;", "\"'<>:=[]()\\/!?%",
"<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>