Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b095d1a183
|
No files matched your search
@@ -44,7 +44,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: >-
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build
|
||||
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~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~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
|
||||
--logger 'trx;LogFileName=reader-regressions.trx'
|
||||
--results-directory artifacts/reader-regressions
|
||||
- name: Retain native regression evidence
|
||||
|
||||
@@ -41,12 +41,21 @@ 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
|
||||
|
||||
@@ -33,22 +33,10 @@ 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. 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.
|
||||
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.
|
||||
|
||||
The foreground launcher permits the primary to focus an existing document.
|
||||
Dispatch all window creation on the native UI thread; duplicate paths include
|
||||
|
||||
@@ -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.Core.Tests/ActivationFuzzTests.cs"
|
||||
"tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs"
|
||||
],
|
||||
"command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz",
|
||||
"workflow": ".github/workflows/fuzz.yml",
|
||||
|
||||
@@ -30,7 +30,8 @@ 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
|
||||
# Weekly and before every MINOR/MAJOR release, repeat the command with 10000.
|
||||
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.
|
||||
$env:K22_FUZZ_ITERATIONS = '10000'
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
|
||||
| Junction/symlink escape or check/reopen race | Check normalized physical path on opened image handle; read same handle; real junction rejection test passes |
|
||||
| Remote image tracking / resource exfiltration | No-referrer header, no document networking; actual listening-server browser denial passed on 77dd04d |
|
||||
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
|
||||
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass. Element nesting is bounded at 256 levels before any library walks the parsed tree recursively, with an early count that stops runaway nesting while it is still being parsed; deeper markup is refused as an unsupported document. Parse time is not bounded: it grows with document size, runs off the window thread, and can be cancelled between the render, the parse and each heading |
|
||||
| Settings corruption / UI exception | AppKit shell sanitation and crash handling; inherited foundation tests |
|
||||
| Tampered installer/update | Fail-closed signing/provenance/checksum release pipeline; real Azure CI authorization and signed artifacts remain unverified |
|
||||
| Privileged repository review abuse | Trusted review workflow only; current-SHA human acknowledgement remains required |
|
||||
@@ -324,13 +324,6 @@ 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
|
||||
|
||||
@@ -1,214 +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.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();
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
@@ -18,16 +18,25 @@ public sealed class DocumentOutline
|
||||
|
||||
|
||||
|
||||
/// <summary>Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.</summary>
|
||||
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide, MarkdownExtensions? extensions = null)
|
||||
/// <summary>
|
||||
/// Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.
|
||||
/// A large document takes time that grows with its size: cancellation is honoured between the
|
||||
/// render, the parse and each heading, so a closed window or a newer load does not wait for it.
|
||||
/// </summary>
|
||||
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide, MarkdownExtensions? extensions = null, CancellationToken cancellation = default)
|
||||
{
|
||||
var sources = new DocumentSourceMap(markdown);
|
||||
var sourceHeadings = new List<DocumentSourceHeading>();
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources));
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
string rendered = new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources);
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(rendered);
|
||||
IElement body = document.Body!;
|
||||
var fragments = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var nextSuffix = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach(IElement element in body.QuerySelectorAll("h1,h2,h3,h4,h5,h6"))
|
||||
{
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
var heading = new DocumentHeading(_headings.Count, element.LocalName[1] - '0', element.TextContent.Trim());
|
||||
_headings.Add(heading);
|
||||
if(element.GetAttribute(sources.AttributeName) is { } identity && sources.TryGetPosition(identity, out (int Start, int End) position))
|
||||
@@ -37,11 +46,15 @@ public sealed class DocumentOutline
|
||||
_elements.Add(element, heading);
|
||||
element.Id = "mv-heading-" + heading.Index.ToString(CultureInfo.InvariantCulture);
|
||||
string slug = Markdig.Helpers.LinkHelper.UrilizeAsGfm(heading.Title);
|
||||
// Repeated titles continue from the last suffix used for that title instead of rescanning from one.
|
||||
string fragment = slug;
|
||||
for(int suffix = 1; fragments.ContainsKey(fragment); suffix++)
|
||||
int suffix = nextSuffix.GetValueOrDefault(slug, 1);
|
||||
while(fragments.ContainsKey(fragment))
|
||||
{
|
||||
fragment = slug + "-" + suffix.ToString(CultureInfo.InvariantCulture);
|
||||
suffix++;
|
||||
}
|
||||
nextSuffix[slug] = suffix;
|
||||
fragments.Add(fragment, element.Id);
|
||||
}
|
||||
foreach(IElement element in body.QuerySelectorAll("[" + sources.AttributeName + "]"))
|
||||
@@ -56,6 +69,7 @@ public sealed class DocumentOutline
|
||||
link.SetAttribute("href", "#" + target);
|
||||
}
|
||||
}
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
foreach(IElement element in body.QuerySelectorAll("*").Reverse())
|
||||
{
|
||||
if(!_elements.ContainsKey(element))
|
||||
|
||||
@@ -142,10 +142,12 @@ internal sealed class DocumentSourceMap
|
||||
string html = string.Join('\n', lines.Take(block.Lines.Count).Select(line => line.Slice.ToString()));
|
||||
int? close = null;
|
||||
bool seenTag = false;
|
||||
var nesting = new HtmlNesting();
|
||||
var parser = new HtmlParser(new HtmlParserOptions
|
||||
{
|
||||
OnToken = (token, range) =>
|
||||
{
|
||||
nesting.Observe(token);
|
||||
if(!seenTag && token.Type is HtmlTokenType.StartTag or HtmlTokenType.EndTag
|
||||
&& token.Name.Length == 2 && token.Name[0] == 'h' && token.Name[1] is >= '1' and <= '6')
|
||||
{
|
||||
@@ -209,11 +211,13 @@ internal sealed class DocumentSourceMap
|
||||
private static HtmlParser CreateSourceParser(Dictionary<int, int> headingEnds)
|
||||
{
|
||||
int pendingHeading = -1;
|
||||
var nesting = new HtmlNesting();
|
||||
return new HtmlParser(new HtmlParserOptions
|
||||
{
|
||||
IsKeepingSourceReferences = true,
|
||||
OnToken = (token, range) =>
|
||||
{
|
||||
nesting.Observe(token);
|
||||
if(token.Name.Length == 2 && token.Name[0] == 'h' && token.Name[1] is >= '1' and <= '6')
|
||||
{
|
||||
if(token.Type == HtmlTokenType.StartTag)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using AngleSharp.Html.Parser.Tokens;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds how deeply authored HTML may nest. Libraries that walk the parsed tree recursively
|
||||
/// overflow the stack on a few thousand levels, and a stack overflow cannot be caught, so the
|
||||
/// bound is enforced before any such walk: a cheap count while tokens arrive stops runaway
|
||||
/// input early, and an exact measurement of the parsed tree applies the real limit.
|
||||
/// </summary>
|
||||
internal sealed class HtmlNesting
|
||||
{
|
||||
/// <summary>The deepest element nesting a document may have; ordinary documents use a few dozen levels.</summary>
|
||||
public const int MaximumDepth = 256;
|
||||
|
||||
/// <summary>Start tags left open before parsing stops; an upper bound on depth that keeps the parser's work small.</summary>
|
||||
public const int MaximumOpenTags = 4096;
|
||||
|
||||
private const string Message = "The document nests HTML more deeply than the supported limit.";
|
||||
|
||||
private static readonly HashSet<string> s_neverOpen = new(StringComparer.Ordinal)
|
||||
{
|
||||
// Void elements have no content.
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr",
|
||||
// A new one of these closes the previous one, so siblings left unclosed do not nest.
|
||||
"caption", "colgroup", "dd", "dt", "li", "optgroup", "option", "p", "tbody", "td", "tfoot", "th", "thead", "tr",
|
||||
};
|
||||
|
||||
private readonly Stack<string> _open = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Counts start tags that are still open. An end tag closes only the tag on top, so the count
|
||||
/// can exceed the real depth for carelessly closed markup but never falls below it.
|
||||
/// </summary>
|
||||
public void Observe(HtmlToken token)
|
||||
{
|
||||
if(token.Type == HtmlTokenType.StartTag && !s_neverOpen.Contains(token.Name))
|
||||
{
|
||||
_open.Push(token.Name);
|
||||
if(_open.Count > MaximumOpenTags)
|
||||
{
|
||||
throw new ArgumentException(Message, nameof(token));
|
||||
}
|
||||
}
|
||||
else if(token.Type == HtmlTokenType.EndTag && _open.TryPeek(out string? top) && string.Equals(top, token.Name, StringComparison.Ordinal))
|
||||
{
|
||||
_open.Pop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Measures the parsed tree without recursion and rejects one deeper than <see cref="MaximumDepth"/>.</summary>
|
||||
public static void ThrowIfTooDeep(INode root)
|
||||
{
|
||||
int depth = 0;
|
||||
INode? node = root;
|
||||
while(node is not null)
|
||||
{
|
||||
if(node.FirstChild is { } child)
|
||||
{
|
||||
node = child;
|
||||
if(++depth > MaximumDepth)
|
||||
{
|
||||
throw new ArgumentException(Message, nameof(root));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// A leaf: climb to the nearest ancestor that has a sibling still to visit.
|
||||
while(node != root && node!.NextSibling is null)
|
||||
{
|
||||
node = node.Parent;
|
||||
depth--;
|
||||
}
|
||||
node = node == root ? null : node!.NextSibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp;
|
||||
using AngleSharp.Css.Parser;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
@@ -24,6 +26,14 @@ public sealed class MarkdownRenderer
|
||||
/// <summary>Bounds parser work before an opened document can consume unbounded memory.</summary>
|
||||
public const int MaximumCharacters = 16 * 1024 * 1024;
|
||||
|
||||
// The sanitizer's own parser settings: scripting on, so noscript content stays text, and its CSS-aware context.
|
||||
private static readonly IBrowsingContext s_sanitizerContext = BrowsingContext.New(Configuration.Default.WithCss(new CssParserOptions
|
||||
{
|
||||
IsIncludingUnknownDeclarations = true,
|
||||
IsIncludingUnknownRules = true,
|
||||
IsToleratingInvalidSelectors = true,
|
||||
}));
|
||||
|
||||
private readonly MarkdownPipeline _pipeline;
|
||||
|
||||
private readonly MarkdownExtensions _extensions;
|
||||
@@ -100,7 +110,23 @@ public sealed class MarkdownRenderer
|
||||
_ => "",
|
||||
} : "";
|
||||
string html = sources is null ? RenderDocument(Markdown.Parse(body, _pipeline)) : sources.Render(body, _pipeline, markdown.Length - body.Length, RenderDocument);
|
||||
return _sanitizer.Sanitize(prefix + html);
|
||||
return Sanitize(prefix + html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses once with the sanitizer's own settings, proves the tree is shallow enough for the
|
||||
/// sanitizer's recursive passes, and only then sanitizes and serializes it.
|
||||
/// </summary>
|
||||
private string Sanitize(string html)
|
||||
{
|
||||
var nesting = new HtmlNesting();
|
||||
var parser = new HtmlParser(new HtmlParserOptions { IsScripting = true, OnToken = (token, _) => nesting.Observe(token) }, s_sanitizerContext);
|
||||
using IHtmlDocument document = parser.ParseDocument("<!doctype html><html><body>" + html);
|
||||
HtmlNesting.ThrowIfTooDeep(document.Body!);
|
||||
_sanitizer.SanitizeDom(document, document.Body!);
|
||||
return document.Body!.ChildNodes.ToHtml(_sanitizer.OutputFormatter);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,21 +150,26 @@ public sealed class MarkdownRenderer
|
||||
var block = (HtmlBlock)node;
|
||||
string html = string.Join('\n', block.Lines.Lines.Take(block.Lines.Count).Select(line => line.Slice.ToString()));
|
||||
var changes = new List<(int Start, int End, string Value)>();
|
||||
var nesting = new HtmlNesting();
|
||||
var parser = new HtmlParser(new HtmlParserOptions
|
||||
{
|
||||
OnToken = (token, range) =>
|
||||
{
|
||||
nesting.Observe(token);
|
||||
if(token.Type is HtmlTokenType.StartTag or HtmlTokenType.EndTag && token.Name is "code" or "pre")
|
||||
{
|
||||
codeDepth = Math.Max(0, codeDepth + (token.Type == HtmlTokenType.StartTag ? 1 : -1));
|
||||
}
|
||||
else if(token.Type == HtmlTokenType.Character && codeDepth == 0)
|
||||
{
|
||||
string text = html[range.Start.Index..range.End.Index];
|
||||
// An unterminated script or comment reports a range past the end of the block.
|
||||
int start = Math.Min(range.Start.Index, html.Length);
|
||||
int end = Math.Clamp(range.End.Index, start, html.Length);
|
||||
string text = html[start..end];
|
||||
string formatted = FormatRawText(text, extendSubscript);
|
||||
if(!string.Equals(text, formatted, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add((range.Start.Index, range.End.Index, formatted));
|
||||
changes.Add((start, end, formatted));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -148,13 +179,7 @@ public sealed class MarkdownRenderer
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var rewritten = new StringBuilder(html);
|
||||
for(int index = changes.Count - 1; index >= 0; index--)
|
||||
{
|
||||
(int start, int end, string value) = changes[index];
|
||||
rewritten.Remove(start, end - start).Insert(start, value);
|
||||
}
|
||||
string[] lines = rewritten.ToString().Split('\n');
|
||||
string[] lines = Rewrite(html, changes).Split('\n');
|
||||
for(int index = 0; index < block.Lines.Count; index++)
|
||||
{
|
||||
block.Lines.Lines[index].Slice = new Markdig.Helpers.StringSlice(lines[index]);
|
||||
@@ -165,6 +190,28 @@ public sealed class MarkdownRenderer
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Applies replacements in one forward pass, so the work is linear in the block. Malformed markup
|
||||
/// can report a text run that starts inside an earlier one; that run is left as authored.
|
||||
/// </summary>
|
||||
private static string Rewrite(string html, List<(int Start, int End, string Value)> changes)
|
||||
{
|
||||
var rewritten = new StringBuilder(html.Length + 64);
|
||||
int copied = 0;
|
||||
foreach((int start, int end, string value) in changes)
|
||||
{
|
||||
if(start < copied)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
rewritten.Append(html, copied, start - copied).Append(value);
|
||||
copied = end;
|
||||
}
|
||||
return rewritten.Append(html, copied, html.Length - copied).ToString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reference extension syntax applies only to tokenized visible text, with bounded regex work and unchanged entity spelling.</summary>
|
||||
private string FormatRawText(string text, bool extendSubscript)
|
||||
{
|
||||
@@ -198,10 +245,12 @@ public sealed class MarkdownRenderer
|
||||
}
|
||||
else if(node is HtmlBlock block)
|
||||
{
|
||||
var nesting = new HtmlNesting();
|
||||
var parser = new HtmlParser(new HtmlParserOptions
|
||||
{
|
||||
OnToken = (token, _) =>
|
||||
{
|
||||
nesting.Observe(token);
|
||||
if(token.Type is HtmlTokenType.StartTag or HtmlTokenType.EndTag && token.Name is "code" or "pre")
|
||||
{
|
||||
codeDepth = Math.Max(0, codeDepth + (token.Type == HtmlTokenType.StartTag ? 1 : -1));
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -6,9 +6,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <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>
|
||||
|
||||
/// <summary>Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol.</summary>
|
||||
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
|
||||
[JsonSerializable(typeof(ActivationRequest))]
|
||||
internal sealed partial class ActivationJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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,8 +3,6 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
using Microsoft.Windows.AppLifecycle;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.Windows.AppLifecycle;
|
||||
using Velopack;
|
||||
@@ -17,21 +16,17 @@ namespace MarkdownViewr.Windows;
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>The running reader's inbox; null in a process that could not create one or that opened its documents itself.</summary>
|
||||
/// <summary>The primary's bounded current-user inbox is attached by App only after the native reader initializes.</summary>
|
||||
internal static ActivationInbox? Inbox { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The documents this launch was asked to open, resolved against its own working folder.</summary>
|
||||
/// <summary>The initial canonical request preserves sender-relative paths and SDK rich file activation.</summary>
|
||||
internal static ActivationRequest InitialRequest { get; private set; } = new([]);
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// <summary>Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request.</summary>
|
||||
[STAThread]
|
||||
private static int Main()
|
||||
{
|
||||
@@ -44,12 +39,12 @@ public static class Program
|
||||
WinRT.ComWrappersSupport.InitializeComWrappers();
|
||||
InitialRequest = DesktopActivation.ReadInitialRequest();
|
||||
string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!);
|
||||
var instance = AppInstance.FindOrRegisterForKey(key);
|
||||
if(!instance.IsCurrent && Forward(key, instance.ProcessId))
|
||||
var primary = AppInstance.FindOrRegisterForKey(key);
|
||||
if(!primary.IsCurrent)
|
||||
{
|
||||
return 0;
|
||||
return ForwardToPrimary(key, primary.ProcessId);
|
||||
}
|
||||
ActivationInbox? inbox = instance.IsCurrent ? OpenInbox(key) : null;
|
||||
using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure);
|
||||
Inbox = inbox;
|
||||
try
|
||||
{
|
||||
@@ -62,54 +57,30 @@ public static class Program
|
||||
finally
|
||||
{
|
||||
Inbox = null;
|
||||
// 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();
|
||||
}
|
||||
primary.UnregisterKey();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
/// <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)
|
||||
{
|
||||
try
|
||||
{
|
||||
DesktopActivation.AllowPrimaryForeground(processId);
|
||||
// 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())
|
||||
bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if(!delivered)
|
||||
{
|
||||
return true;
|
||||
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
|
||||
}
|
||||
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
|
||||
return delivered ? 0 : 1;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException)
|
||||
{
|
||||
DesktopActivation.ReportFailure(exception);
|
||||
return 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,343 +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;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deeply nested markup must be refused as an ordinary error. The renders here run on a thread
|
||||
/// with a quarter of the default stack, so a recursive walk that the bound failed to stop would
|
||||
/// end the test run instead of passing by luck.
|
||||
/// </summary>
|
||||
public sealed class HtmlNestingTests
|
||||
{
|
||||
private const int SmallStackBytes = 256 * 1024;
|
||||
|
||||
|
||||
|
||||
/// <summary>The deepest markup the bound allows renders completely, with stack to spare.</summary>
|
||||
[Fact]
|
||||
public void Render_NestingAtTheLimit_RendersOnASmallStack()
|
||||
{
|
||||
int levels = HtmlNesting.MaximumDepth - 1;
|
||||
string markup = Repeat("<div>", levels) + "deepest" + Repeat("</div>", levels);
|
||||
string? html = null;
|
||||
Assert.Null(OnSmallStack(() => html = new MarkdownRenderer().Render(markup)));
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(html!);
|
||||
Assert.Equal(levels, parsed.QuerySelectorAll("div").Length);
|
||||
Assert.Contains("deepest", html, StringComparison.Ordinal);
|
||||
Assert.Equal(256, HtmlNesting.MaximumDepth);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>One level past the limit is an argument error, the failure the reader already reports for an unsupported document.</summary>
|
||||
[Fact]
|
||||
public void Render_NestingOnePastTheLimit_IsRejected()
|
||||
{
|
||||
string markup = Repeat("<div>", HtmlNesting.MaximumDepth) + "too deep";
|
||||
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A few kilobytes of nested elements, in every shape that nests, are refused before any
|
||||
/// library walks the tree recursively.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("<div>", 3000)]
|
||||
[InlineData("<b>", 3000)]
|
||||
[InlineData("<span><i>", 1500)]
|
||||
[InlineData("<table><tr><td>", 3000)]
|
||||
[InlineData("<blockquote>", 2000)]
|
||||
[InlineData("<ul><li>", 2000)]
|
||||
[InlineData("<details><summary>s</summary>", 1500)]
|
||||
[InlineData("<div></section>", 3000)]
|
||||
public void Render_DeeplyNestedMarkup_IsRejectedBeforeAnyRecursiveWalk(string unit, int count)
|
||||
{
|
||||
string markup = Repeat(unit, count) + "x";
|
||||
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup)));
|
||||
Assert.IsType<ArgumentException>(OnSmallStack(() => _ = new DocumentOutline(markup)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Nesting far past the limit is stopped by the count kept while tokens arrive, not after the
|
||||
/// whole input has been parsed: parsing it to the end takes time that grows with its square.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Render_RunawayNesting_IsStoppedWhileParsing()
|
||||
{
|
||||
string block = Repeat("<div>", 100000) + "x";
|
||||
ArgumentException stoppedInBlock = Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(block)));
|
||||
Assert.Equal("token", stoppedInBlock.ParamName);
|
||||
string inline = "Paragraph " + Repeat("<b>", 100000) + "x";
|
||||
ArgumentException stoppedInline = Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(inline)));
|
||||
Assert.Equal("token", stoppedInline.ParamName);
|
||||
ArgumentException measured = Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(Repeat("<div>", 300) + "x")));
|
||||
Assert.Equal("root", measured.ParamName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Nesting assembled from inline tags in a paragraph, or from many separate blocks, is measured as a whole.</summary>
|
||||
[Theory]
|
||||
[InlineData("Paragraph ", "<b>", "")]
|
||||
[InlineData("", "<div>", "\n\n")]
|
||||
[InlineData("- item ", "<span>", "")]
|
||||
[InlineData("> quoted ", "<i>", "")]
|
||||
public void Render_NestingAcrossInlineTagsAndBlocks_IsRejected(string prefix, string unit, string separator)
|
||||
{
|
||||
string markup = prefix + string.Concat(Enumerable.Repeat(unit + separator, 400)) + "x";
|
||||
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Nesting hidden in frontmatter rendered as Markdown is bounded like the body.</summary>
|
||||
[Fact]
|
||||
public void Render_DeepNestingInMarkdownFrontmatter_IsRejected()
|
||||
{
|
||||
string markup = "---\n" + Repeat("<div>", 3000) + "\n---\nBody";
|
||||
Assert.IsType<ArgumentException>(OnSmallStack(() => new MarkdownRenderer().Render(markup, FrontmatterMode.Markdown)));
|
||||
Assert.Null(OnSmallStack(() => new MarkdownRenderer().Render(markup, FrontmatterMode.Hide)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Wide documents are not deep: thousands of siblings, unclosed paragraphs, list items and cells render normally.</summary>
|
||||
[Theory]
|
||||
[InlineData("<div>x</div>")]
|
||||
[InlineData("<p>x")]
|
||||
[InlineData("x<br>")]
|
||||
[InlineData("<span>x</span><b>y</b>")]
|
||||
public void Render_ManySiblings_AreNotMistakenForNesting(string unit)
|
||||
{
|
||||
string markup = "<div>" + Repeat(unit, HtmlNesting.MaximumOpenTags + 500) + "</div>";
|
||||
string? html = null;
|
||||
Assert.Null(OnSmallStack(() => html = new MarkdownRenderer().Render(markup)));
|
||||
Assert.Contains("x", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>List items and table cells left unclosed are siblings, however many there are.</summary>
|
||||
[Fact]
|
||||
public void Render_ManyUnclosedItemsAndCells_AreNotMistakenForNesting()
|
||||
{
|
||||
string markup = "<ul>" + Repeat("<li>item", 5000) + "</ul>\n\n<table>" + Repeat("<tr><td>a<td>b", 5000) + "</table>";
|
||||
string? html = null;
|
||||
Assert.Null(OnSmallStack(() => html = new MarkdownRenderer().Render(markup)));
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(html!);
|
||||
Assert.Equal(5000, parsed.QuerySelectorAll("li").Length);
|
||||
Assert.Equal(10000, parsed.QuerySelectorAll("td").Length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The early count allows exactly its bound of open tags and stops at the next one.</summary>
|
||||
[Theory]
|
||||
[InlineData(HtmlNesting.MaximumOpenTags, false)]
|
||||
[InlineData(HtmlNesting.MaximumOpenTags + 1, true)]
|
||||
public void Observe_OpenTagsAtAndPastTheBound_StopsExactlyPastIt(int count, bool stopped)
|
||||
{
|
||||
Assert.Equal(stopped, Record.Exception(() => Tokenize(Repeat("<div>", count))) is ArgumentException);
|
||||
Assert.Equal(4096, HtmlNesting.MaximumOpenTags);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The early count never falls below the real depth: an end tag closes only the tag on top,
|
||||
/// so an end tag the parser would ignore cannot be used to hide nesting from the count.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("<div></span>")]
|
||||
[InlineData("<div><table><tr><td></div>")]
|
||||
[InlineData("<section><p></article>")]
|
||||
public void Observe_EndTagThatDoesNotCloseTheOpenTag_DoesNotReduceTheCount(string unit)
|
||||
=> Assert.IsType<ArgumentException>(Record.Exception(() => Tokenize(Repeat(unit, HtmlNesting.MaximumOpenTags + 1))));
|
||||
|
||||
|
||||
|
||||
/// <summary>Properly closed elements, void elements and self-closing siblings never accumulate.</summary>
|
||||
[Theory]
|
||||
[InlineData("<div><span>x</span></div>")]
|
||||
[InlineData("<img src=x><br><hr><input>")]
|
||||
[InlineData("<p>x<li>y<td>z<option>o")]
|
||||
public void Observe_ClosedVoidAndImplicitlyClosedTags_DoNotAccumulate(string unit)
|
||||
=> Assert.Null(Record.Exception(() => Tokenize(Repeat(unit, HtmlNesting.MaximumOpenTags + 1))));
|
||||
|
||||
|
||||
|
||||
/// <summary>The exact measurement walks every branch and counts from the root it is given.</summary>
|
||||
[Fact]
|
||||
public void ThrowIfTooDeep_ShallowWideAndBranchingTrees_AreAccepted()
|
||||
{
|
||||
using IHtmlDocument empty = new HtmlParser().ParseDocument("");
|
||||
HtmlNesting.ThrowIfTooDeep(empty.Body!);
|
||||
using IHtmlDocument branching = new HtmlParser().ParseDocument(Repeat("<div>", 200) + "a" + Repeat("</div>", 200) + "<p>b</p>" + Repeat("<span>c</span>", 2000) + Repeat("<div>", 254) + "d");
|
||||
HtmlNesting.ThrowIfTooDeep(branching.Body!);
|
||||
using IHtmlDocument late = new HtmlParser().ParseDocument("<p>first</p><p>second</p>" + Repeat("<div>", 256) + "deep");
|
||||
Assert.Throws<ArgumentException>(() => HtmlNesting.ThrowIfTooDeep(late.Body!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Feeds markup through a parser whose tokens go to a fresh count, as the renderer does.</summary>
|
||||
private static void Tokenize(string html)
|
||||
{
|
||||
var nesting = new HtmlNesting();
|
||||
using IHtmlDocument document = new HtmlParser(new HtmlParserOptions { OnToken = (token, _) => nesting.Observe(token) }).ParseDocument(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs on a thread with a quarter of the default stack and returns what it threw, if anything.</summary>
|
||||
private static Exception? OnSmallStack(Action action)
|
||||
{
|
||||
Exception? failure = null;
|
||||
var thread = new Thread(() => failure = Record.Exception(action), SmallStackBytes);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
return failure;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The unit repeated without separators.</summary>
|
||||
private static string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count));
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Malformed and very repetitive documents render, with work that grows in step with their size.</summary>
|
||||
public sealed class RendererRobustnessTests
|
||||
{
|
||||
/// <summary>An unterminated script followed by a comment opener reports a text run past the end of its block; the document still opens and stays passive.</summary>
|
||||
[Theory]
|
||||
[InlineData("<script>x<!--")]
|
||||
[InlineData("<script><!--")]
|
||||
[InlineData("<script>==a==\n<!--")]
|
||||
[InlineData("<script>*<!--")]
|
||||
[InlineData("<div>==kept==</div><script>\n<!--\n")]
|
||||
public void Render_UnterminatedScriptAndComment_OpensAsPassiveMarkup(string markdown)
|
||||
{
|
||||
string html = new MarkdownRenderer().Render(markdown + "\n\nAfter the block.");
|
||||
Assert.DoesNotContain("<script", html, StringComparison.OrdinalIgnoreCase);
|
||||
var outline = new DocumentOutline("# Title\n\n" + markdown);
|
||||
Assert.Equal("Title", Assert.Single(outline.Headings).Title);
|
||||
Assert.DoesNotContain("<script", outline.Html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Markup is parsed with scripting on, as the sanitizer library parses it: the content of a
|
||||
/// noscript element is text that ends at the first closing tag, wherever that tag appears.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Render_NoscriptContent_IsParsedAsTextEndingAtItsFirstClosingTag()
|
||||
{
|
||||
const string Markdown = "<noscript><p title=\"</noscript><img src=x onerror=execute() alt=after>\"></noscript>";
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(Markdown));
|
||||
AngleSharp.Dom.IElement image = Assert.Single(parsed.QuerySelectorAll("img"));
|
||||
Assert.Equal(["alt", "src"], image.Attributes.Select(attribute => attribute.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Empty(parsed.QuerySelectorAll("noscript,p[title]"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Markup broken so that one text run starts inside another is left as authored there, formatted elsewhere, and still sanitized.</summary>
|
||||
[Fact]
|
||||
public void Render_OverlappingTextRuns_RendersWithoutRewritingTheOverlap()
|
||||
{
|
||||
const string Markdown = "<iframe srcdoc='<script>execute()</scr<pre><code>==literal== ^literal^ ++literal++</code></pre>\nipt>'></iframe></style><script>execute()</script>";
|
||||
string html = new MarkdownRenderer().Render(Markdown);
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(html);
|
||||
Assert.Empty(parsed.QuerySelectorAll("script,iframe,style"));
|
||||
Assert.DoesNotContain("execute()</script>", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Every text run in a long raw block is formatted once, in place, in document order.</summary>
|
||||
[Fact]
|
||||
public void Render_ManyFormattedRunsInOneBlock_FormatsEachInOrder()
|
||||
{
|
||||
const int Runs = 3000;
|
||||
string block = "<div>" + string.Concat(Enumerable.Range(0, Runs).Select(index => $"<span>==m{index}== and ^s{index}^</span>")) + "</div>";
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(block));
|
||||
Assert.Equal(Enumerable.Range(0, Runs).Select(index => "m" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)), parsed.QuerySelectorAll("mark").Select(mark => mark.TextContent), StringComparer.Ordinal);
|
||||
Assert.Equal(Enumerable.Range(0, Runs).Select(index => "s" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)), parsed.QuerySelectorAll("sup").Select(sup => sup.TextContent), StringComparer.Ordinal);
|
||||
Assert.Equal(Runs, parsed.QuerySelectorAll("span").Length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A raw block whose text needs no formatting is passed through unchanged between its neighbours.</summary>
|
||||
[Fact]
|
||||
public void Render_FormattedRunsBetweenUnchangedText_KeepsTheTextAroundThem()
|
||||
{
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new MarkdownRenderer().Render("<div>before <b>==first==</b> middle <i>plain</i> ++last++ after</div>"));
|
||||
Assert.Equal("before first middle plain last after", parsed.Body!.TextContent.Trim());
|
||||
Assert.Equal("first", Assert.Single(parsed.QuerySelectorAll("mark")).TextContent);
|
||||
Assert.Equal("last", Assert.Single(parsed.QuerySelectorAll("ins")).TextContent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Thousands of headings with one title get distinct anchors, and a link to any of them finds its heading.</summary>
|
||||
[Fact]
|
||||
public void Outline_ManyHeadingsWithTheSameTitle_AnchorEachOne()
|
||||
{
|
||||
const int Count = 4000;
|
||||
string markdown = string.Concat(Enumerable.Repeat("# Same\n\n", Count)) + "[first](#same) [second](#same-1) [last](#same-3999) [none](#same-4000)\n";
|
||||
var outline = new DocumentOutline(markdown);
|
||||
Assert.Equal(Count, outline.Headings.Count);
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(outline.Html);
|
||||
Assert.Equal(["#mv-heading-0", "#mv-heading-1", "#mv-heading-3999", "#same-4000"], parsed.QuerySelectorAll("a").Select(link => link.GetAttribute("href")!), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A heading that is literally named like a generated anchor keeps its own anchor; repeats step past it.</summary>
|
||||
[Theory]
|
||||
[InlineData("# Same\n\n# Same-1\n\n# Same\n\n# Same\n\n", new[] { "#same", "#same-1", "#same-2", "#same-3" })]
|
||||
[InlineData("# Same-1\n\n# Same\n\n# Same\n\n# Same-1\n\n", new[] { "#same-1", "#same", "#same-2", "#same-1-1" })]
|
||||
[InlineData("# A\n\n# B\n\n# A\n\n# B\n\n", new[] { "#a", "#b", "#a-1", "#b-1" })]
|
||||
public void Outline_RepeatedAndLiteralSuffixTitles_AnchorInDocumentOrder(string headings, string[] anchors)
|
||||
{
|
||||
string markdown = headings + string.Concat(anchors.Select(anchor => $"[link]({anchor}) "));
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(new DocumentOutline(markdown).Html);
|
||||
Assert.Equal(["#mv-heading-0", "#mv-heading-1", "#mv-heading-2", "#mv-heading-3"], parsed.QuerySelectorAll("a").Select(link => link.GetAttribute("href")!), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A load that is no longer wanted stops instead of building its outline.</summary>
|
||||
[Fact]
|
||||
public void Outline_CancelledBeforeItStarts_StopsWithoutBuilding()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() => new DocumentOutline("# Title\n\nBody", cancellation: cancellation.Token));
|
||||
Assert.Equal("Title", Assert.Single(new DocumentOutline("# Title\n\nBody", cancellation: CancellationToken.None).Headings).Title);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -6,10 +6,9 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <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,164 @@
|
||||
/*
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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,6 +36,9 @@
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\DocumentWatcher.cs" Link="WindowsAdapters\DocumentWatcher.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\FileTypeRegistration.cs" Link="WindowsAdapters\FileTypeRegistration.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ShellFileAssociations.cs" Link="WindowsAdapters\ShellFileAssociations.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationRequest.cs" Link="WindowsAdapters\ActivationRequest.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationJsonContext.cs" Link="WindowsAdapters\ActivationJsonContext.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationInbox.cs" Link="WindowsAdapters\ActivationInbox.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user