diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4008b23..2d71fd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: shell: pwsh run: >- dotnet test tests/MarkdownViewr.Windows.UiTests --no-build - --filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse' + --filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~Outline_NestedCollapse' --logger 'trx;LogFileName=reader-regressions.trx' --results-directory artifacts/reader-regressions - name: Retain native regression evidence diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index f02dc3f..2ad4bab 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -41,21 +41,12 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } dotnet build tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 -warnaserror --no-restore if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Restore and build activation adapter targets - shell: pwsh - run: | - dotnet restore tests/MarkdownViewr.Windows.UiTests -p:Platform=x64 --locked-mode - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet build tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 -warnaserror --no-restore - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify seeded parser security properties shell: pwsh run: | Write-Output "Fuzz seeds22023/99173, five targets, $env:K22_FUZZ_ITERATIONS iterations per seed/target." dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=fuzz.trx' --results-directory artifacts/fuzz if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=activation-fuzz.trx' --results-directory artifacts/fuzz - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Retain synthetic campaign failures and results if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/docs/adr/0005-windows-file-registration-and-activation.md b/docs/adr/0005-windows-file-registration-and-activation.md index 1aafe63..7109bef 100644 --- a/docs/adr/0005-windows-file-registration-and-activation.md +++ b/docs/adr/0005-windows-file-registration-and-activation.md @@ -33,10 +33,22 @@ crossing processes; the public SDK launch envelope cannot carry sender CWD. Deliver requests through a current-user-only .NET named pipe. Accept only a source-generated bounded schema: at most128 absolute paths, a1MiB frame and an optional fixed invalid-path notification. Reject unknown fields; no command or -script operation exists. Connection/read/acknowledgement waits are bounded to -10seconds, with at most16 requests queued before XAML attaches its dispatcher. -Malformed/disconnected peers cannot stop later valid requests. App shutdown -cancels and disposes the listener. No dependencies or generic RPC layer are added. +script operation exists. A sender waits at most2 seconds for the reader's pipe +and each side at most10 seconds for one exchange, with at most16 requests held +before XAML attaches its dispatcher. Malformed/disconnected peers cannot stop +later valid requests. App shutdown cancels and disposes the listener, releasing +the pipe name before the instance key. No dependencies or generic RPC layer are added. + +The request, the schema and the inbox live in the core library +(`Core/Activation`), where the unit tests and the coverage gate reach them. + +Forwarding is best effort; opening the document is not. A launch whose request +the running reader did not take (no listener, a refusal, or a deadline) opens +its documents in its own process. A reader that cannot create its inbox, because +the pipe name is already held by a reader that is still closing or by another +program, runs without one; later launches then open their own documents. An +argument that cannot name a document is dropped and reported in the reader +window while the usable arguments still open. The foreground launcher permits the primary to focus an existing document. Dispatch all window creation on the native UI thread; duplicate paths include diff --git a/docs/security/assurance.json b/docs/security/assurance.json index 3a35dac..197daa9 100644 --- a/docs/security/assurance.json +++ b/docs/security/assurance.json @@ -11,7 +11,7 @@ "reason": "Untrusted Markdown/HTML/frontmatter, source ranges/folds, resource URLs, imported theme JSON and activation JSON are parser boundaries. Seeded mutation targets verify unsafe acceptance as well as exceptions.", "targets": [ "tests/MarkdownViewr.Windows.Core.Tests/SecurityFuzzTests.cs", - "tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs" + "tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs" ], "command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz", "workflow": ".github/workflows/fuzz.yml", diff --git a/docs/security/fuzzing.md b/docs/security/fuzzing.md index fd735b2..a1e98d0 100644 --- a/docs/security/fuzzing.md +++ b/docs/security/fuzzing.md @@ -30,8 +30,7 @@ Run from the repository root with the pinned .NET 10 SDK on Windows: dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode $env:K22_FUZZ_ITERATIONS = '500' dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --filter Category=Fuzz -dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --filter Category=Fuzz -# Weekly and before every MINOR/MAJOR release, repeat both commands with 10000. +# Weekly and before every MINOR/MAJOR release, repeat the command with 10000. $env:K22_FUZZ_ITERATIONS = '10000' ``` diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 37506ff..bc77b72 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -324,6 +324,13 @@ relative/control-containing/null/overlong paths and more than128 paths; frames are bounded to1MiB, connection/read waits to10seconds and pre-XAML queuing to16 requests. Normal document size/resource/sanitizer boundaries still apply. +The pipe name is derived from the executable and data folders, so another +program running as any user on the machine can create that name first. That +cannot expose a document or redirect one: both ends require a pipe owned by the +current user. It could only deny forwarding, so a reader that cannot create its +inbox starts without one, and a launch whose request is not taken opens its own +documents. + Malformed/disconnected requests are rejected and the listener continues; owned shutdown cancels the pipe. Fixed notifications and failure kind/HRESULT logging do not expose paths or payloads. UI-dispatched duplicate activation preserves diff --git a/src/MarkdownViewr.Windows.Core/Activation/ActivationInbox.cs b/src/MarkdownViewr.Windows.Core/Activation/ActivationInbox.cs new file mode 100644 index 0000000..9ccb448 --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Activation/ActivationInbox.cs @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using System.Buffers.Binary; +using System.IO.Pipes; +using System.Text.Json; + + +namespace MarkdownViewr.Windows.Core.Activation; + +/// +/// 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 +/// per connection and answers with one byte: accepted or not. +/// +public sealed class ActivationInbox : IDisposable +{ + /// Requests held while the reader is still starting; a further one is refused so the sender can report it. + public const int MaximumPendingRequests = 16; + + /// How long a sender waits for the reader's pipe to exist and be free. + public static readonly TimeSpan ConnectDeadline = TimeSpan.FromSeconds(2); + + /// How long either side waits for the other to finish one request. + public static readonly TimeSpan ExchangeDeadline = TimeSpan.FromSeconds(10); + + private readonly Lock _gate = new(); + private readonly Queue _pending = new(); + private readonly CancellationTokenSource _closed = new(); + private readonly NamedPipeServerStream _pipe; + private readonly Action _report; + private readonly TimeProvider _time; + private readonly Task _listener; + private Func? _dispatch; + private int _disposed; + + + + /// + /// 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 , the system clock unless a test supplies its own. + /// + public ActivationInbox(string key, Action 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(); + } + + + + /// Completes when the listener has stopped after . + public Task Completion => _listener; + + + + /// + /// 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. + /// + public void Attach(Func 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.")); + } + } + } + } + + + + /// Sends one request to the running reader and returns whether it accepted it. + public static Task SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken) + => SendAsync(key, request, TimeProvider.System, cancellationToken); + + + + /// 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. + internal static async Task 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; + } + + + + /// 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. + 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(); + } + } + } + } + } + + + + /// Reads one bounded, length-prefixed request and checks it against the schema and the path rules. + private async Task 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; + } + + + + /// Hands the request to the reader, or holds it until the reader attaches; false when neither is possible. + 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; + } + } + + + + /// Stops listening and releases the pipe name. + public void Dispose() + { + if(Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + lock(_gate) + { + _closed.Cancel(); + _pipe.Dispose(); + } + _closed.Dispose(); + } +} diff --git a/src/MarkdownViewr.Windows/ActivationJsonContext.cs b/src/MarkdownViewr.Windows.Core/Activation/ActivationJsonContext.cs similarity index 61% rename from src/MarkdownViewr.Windows/ActivationJsonContext.cs rename to src/MarkdownViewr.Windows.Core/Activation/ActivationJsonContext.cs index 380f3e2..6566f17 100644 --- a/src/MarkdownViewr.Windows/ActivationJsonContext.cs +++ b/src/MarkdownViewr.Windows.Core/Activation/ActivationJsonContext.cs @@ -6,10 +6,9 @@ using System.Text.Json.Serialization; -namespace MarkdownViewr.Windows; +namespace MarkdownViewr.Windows.Core.Activation; - -/// Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol. +/// 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. [JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(ActivationRequest))] internal sealed partial class ActivationJsonContext : JsonSerializerContext; diff --git a/src/MarkdownViewr.Windows.Core/Activation/ActivationRequest.cs b/src/MarkdownViewr.Windows.Core/Activation/ActivationRequest.cs new file mode 100644 index 0000000..81e3d64 --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Activation/ActivationRequest.cs @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using System.Security.Cryptography; +using System.Text; + + +namespace MarkdownViewr.Windows.Core.Activation; + +/// +/// 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. +/// +public sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false) +{ + /// The most documents one launch may open. + public const int MaximumPaths = 128; + + /// The largest serialized request the running reader accepts. + public const int MaximumPayloadBytes = 1024 * 1024; + + private const int MaximumPathCharacters = 32767; + + + + /// + /// 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. + /// + public static ActivationRequest Create(IEnumerable 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().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), resolved.Contains(null)); + } + + + + /// Rejects a request that did not come from : a relative, empty, overlong or control-bearing path, or too many of them. + public void Validate() + { + if(Paths is null || Paths.Length > MaximumPaths || !Paths.All(IsUsable)) + { + throw new InvalidDataException("Invalid document activation paths."); + } + } + + + + /// + /// 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. + /// + 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))); + } + + + + /// The absolute form of one argument, or null when it cannot name a document. + 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; + } + } + + + + /// A path the reader may be asked to open: absolute, bounded, and free of control characters. + private static bool IsUsable(string? path) + => !string.IsNullOrEmpty(path) && path.Length <= MaximumPathCharacters && Path.IsPathFullyQualified(path) && !path.Any(char.IsControl); +} diff --git a/src/MarkdownViewr.Windows/ActivationInbox.cs b/src/MarkdownViewr.Windows/ActivationInbox.cs deleted file mode 100644 index 78b35d6..0000000 --- a/src/MarkdownViewr.Windows/ActivationInbox.cs +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright (c) 2026 James Maes - * SPDX-License-Identifier: MIT - */ - -using System.Buffers.Binary; -using System.IO.Pipes; -using System.Text.Json; - - -namespace MarkdownViewr.Windows; - -/// Bounded current-user IPC delivers sender-canonicalized document paths to the SDK's chosen primary instance. -internal sealed class ActivationInbox : IDisposable -{ - private readonly Lock _gate = new(); - private readonly Queue _pending = new(); - private readonly CancellationTokenSource _closed = new(); - private readonly NamedPipeServerStream _pipe; - private readonly Action _report; - private readonly Task _listener; - private Func? _dispatch; - private int _disposed; - - - - /// The primary owns the pipe before XAML starts; only the same Windows user can connect. - internal ActivationInbox(string key, Action report) - { - _report = report; - _pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); - _listener = ListenAsync(); - } - - - - /// The owned listener completes on disposal; tests and shutdown diagnostics can observe failures instead of losing a task. - internal Task Completion => _listener; - - - - /// Attaches the native UI dispatcher after launch, delivering bounded requests received while XAML initialized. - internal void Attach(Func dispatch) - { - lock(_gate) - { - _dispatch = dispatch; - while(_pending.TryDequeue(out ActivationRequest? request)) - { - if(!dispatch(request)) - { - _report(new IOException("The reader could not accept activation.")); - } - } - } - } - - - - /// Sends one fixed request with a bounded connection/acknowledgement wait and preserves the real server acknowledgement. - internal static async Task 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; - } - - - - /// A disconnected or malformed peer cannot stop later activations; each request has a bounded read deadline. - 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. - } - } - } - } - } - - - - /// A startup burst can queue at most16 requests; the peer is told to retry if the native reader is busy. - private bool QueueBeforeLaunch(ActivationRequest request) - { - if(_pending.Count >= 16) - { - return false; - } - _pending.Enqueue(request); - return true; - } - - - - /// Application shutdown cancels pending connections and releases the owned current-user pipe. - public void Dispose() - { - if(Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - _closed.Cancel(); - _pipe.Dispose(); - _closed.Dispose(); - } -} diff --git a/src/MarkdownViewr.Windows/ActivationRequest.cs b/src/MarkdownViewr.Windows/ActivationRequest.cs deleted file mode 100644 index 31682fc..0000000 --- a/src/MarkdownViewr.Windows/ActivationRequest.cs +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2026 James Maes - * SPDX-License-Identifier: MIT - */ - -using System.Security.Cryptography; -using System.Text; - - -namespace MarkdownViewr.Windows; - -/// Only bounded literal absolute document paths cross the current-user activation pipe; no native commands are accepted. -internal sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false) -{ - internal const int MaximumPaths = 128; - internal const int MaximumPayloadBytes = 1024 * 1024; - - - - /// Canonicalizes at the sender, preserving relative-path meaning even when the existing reader has a different working directory. - internal static ActivationRequest Create(IEnumerable 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; - } - - - - /// Malformed, oversized or relative pipe input cannot become an unbounded launch or be interpreted using the receiver's directory. - 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."); - } - } - - - - /// Profile and executable directories define distinct stable/dev, development-build and review/test instance identities. - 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))); - } -} diff --git a/src/MarkdownViewr.Windows/App.Activation.cs b/src/MarkdownViewr.Windows/App.Activation.cs index e46812e..89b77e3 100644 --- a/src/MarkdownViewr.Windows/App.Activation.cs +++ b/src/MarkdownViewr.Windows/App.Activation.cs @@ -3,6 +3,8 @@ * SPDX-License-Identifier: MIT */ +using MarkdownViewr.Windows.Core.Activation; + namespace MarkdownViewr.Windows; diff --git a/src/MarkdownViewr.Windows/DesktopActivation.cs b/src/MarkdownViewr.Windows/DesktopActivation.cs index 64161a4..046e9e9 100644 --- a/src/MarkdownViewr.Windows/DesktopActivation.cs +++ b/src/MarkdownViewr.Windows/DesktopActivation.cs @@ -4,6 +4,7 @@ */ using System.Runtime.InteropServices; +using MarkdownViewr.Windows.Core.Activation; using Microsoft.Windows.AppLifecycle; using Windows.ApplicationModel.Activation; diff --git a/src/MarkdownViewr.Windows/Program.cs b/src/MarkdownViewr.Windows/Program.cs index 2aba1d2..6eac9cf 100644 --- a/src/MarkdownViewr.Windows/Program.cs +++ b/src/MarkdownViewr.Windows/Program.cs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +using MarkdownViewr.Windows.Core.Activation; using Microsoft.UI.Dispatching; using Microsoft.Windows.AppLifecycle; using Velopack; @@ -16,17 +17,21 @@ namespace MarkdownViewr.Windows; /// public static class Program { - /// The primary's bounded current-user inbox is attached by App only after the native reader initializes. + /// The running reader's inbox; null in a process that could not create one or that opened its documents itself. internal static ActivationInbox? Inbox { get; private set; } - /// The initial canonical request preserves sender-relative paths and SDK rich file activation. + /// The documents this launch was asked to open, resolved against its own working folder. internal static ActivationRequest InitialRequest { get; private set; } = new([]); - /// Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request. + /// + /// 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. + /// [STAThread] private static int Main() { @@ -39,12 +44,12 @@ public static class Program WinRT.ComWrappersSupport.InitializeComWrappers(); InitialRequest = DesktopActivation.ReadInitialRequest(); string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!); - var primary = AppInstance.FindOrRegisterForKey(key); - if(!primary.IsCurrent) + var instance = AppInstance.FindOrRegisterForKey(key); + if(!instance.IsCurrent && Forward(key, instance.ProcessId)) { - return ForwardToPrimary(key, primary.ProcessId); + return 0; } - using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure); + ActivationInbox? inbox = instance.IsCurrent ? OpenInbox(key) : null; Inbox = inbox; try { @@ -57,30 +62,54 @@ public static class Program finally { Inbox = null; - primary.UnregisterKey(); + // The pipe name is released before the instance key, so the next reader to register can create its own inbox. + inbox?.Dispose(); + if(instance.IsCurrent) + { + instance.UnregisterKey(); + } } return 0; } - /// Current-user .NET pipe delivery avoids the SDK STA redirection deadlock and resolves relative paths before crossing processes. - private static int ForwardToPrimary(string key, uint processId) + /// + /// 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. + /// + 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; + } + } + + + + /// Hands this launch's documents to the running reader; false when it did not take them. + private static bool Forward(string key, uint processId) { try { DesktopActivation.AllowPrimaryForeground(processId); - bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult(); - if(!delivered) + // A plain pipe is used because the SDK's own redirection deadlocks an STA entry point. + if(ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult()) { - DesktopActivation.ReportFailure(new IOException("The running reader is busy.")); + return true; } - return delivered ? 0 : 1; + DesktopActivation.ReportFailure(new IOException("The running reader is busy.")); } catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException) { DesktopActivation.ReportFailure(exception); - return 1; } + return false; } } diff --git a/tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs similarity index 97% rename from tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs rename to tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs index a1ddc5b..a1b7979 100644 --- a/tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs +++ b/tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs @@ -6,9 +6,10 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; +using MarkdownViewr.Windows.Core.Activation; -namespace MarkdownViewr.Windows.UiTests; +namespace MarkdownViewr.Windows.Core.Tests; /// Fuzzes the real generated activation schema and path validator without creating a window, pipe, process or profile. [Trait("Category", "Fuzz")] diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ActivationInboxTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ActivationInboxTests.cs new file mode 100644 index 0000000..f47a860 --- /dev/null +++ b/tests/MarkdownViewr.Windows.Core.Tests/ActivationInboxTests.cs @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.IO.Pipes; +using System.Text; +using MarkdownViewr.Windows.Core.Activation; + + +namespace MarkdownViewr.Windows.Core.Tests; + +/// +/// 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. +/// +public sealed class ActivationInboxTests +{ + private static readonly TimeSpan s_safetyNet = TimeSpan.FromSeconds(30); + + + + /// A request sent while the reader is starting is held until it attaches; afterwards each request gets the reader's own answer. + [Fact] + public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement() + { + string key = NewKey(); + var received = new ConcurrentQueue(); + var errors = new ConcurrentQueue(); + 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); + } + + + + /// While the reader is starting it holds a bounded number of requests and refuses the next, which the sender is told. + [Fact] + public async Task Send_MoreThanTheBoundBeforeTheReaderAttaches_RefusesTheExtraRequest() + { + string key = NewKey(); + var received = new ConcurrentQueue(); + 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); + } + + + + /// A held request the reader then cannot take is reported, not silently dropped. + [Fact] + public async Task Attach_HeldRequestTheReaderRefuses_IsReported() + { + string key = NewKey(); + var errors = new ConcurrentQueue(); + 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(Assert.Single(errors)); + Assert.Throws(() => inbox.Attach(null!)); + Assert.Throws(() => new ActivationInbox(NewKey(), null!)); + } + + + + /// + /// 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. + /// + [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(() => 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)); + } + + + + /// 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. + [Fact] + public async Task Listen_MalformedPeers_RejectsAndRecovers() + { + string key = NewKey(); + var received = new ConcurrentQueue(); + var errors = new ConcurrentQueue(); + 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); + } + + + + /// A frame of exactly the largest size is read and accepted. + [Fact] + public async Task Listen_FrameOfExactlyTheLargestSize_IsAccepted() + { + string key = NewKey(); + var received = new ConcurrentQueue(); + var errors = new ConcurrentQueue(); + 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); + } + + + + /// A sender never puts an invalid or oversized request on the pipe. + [Fact] + public async Task Send_InvalidOrOversizedRequest_IsRefusedBeforeConnecting() + { + string key = NewKey(); + using var safety = new CancellationTokenSource(s_safetyNet); + await Assert.ThrowsAsync(() => ActivationInbox.SendAsync(key, new ActivationRequest(["relative.md"]), safety.Token)); + await Assert.ThrowsAsync(() => 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(() => ActivationInbox.SendAsync(key, oversized, safety.Token)); + } + + + + /// With no reader listening, a sender gives up at its connect deadline instead of waiting forever. + [Fact] + public async Task Send_NoReaderListening_GivesUpAtTheConnectDeadline() + { + var clock = new ManualTimeProvider(); + using var safety = new CancellationTokenSource(s_safetyNet); + Task sending = ActivationInbox.SendAsync(NewKey(), new ActivationRequest([]), clock, safety.Token); + await clock.NextTimerAsync(safety.Token); + Assert.False(sending.IsCompleted); + clock.Elapse(); + await Assert.ThrowsAnyAsync(() => sending); + Assert.False(safety.IsCancellationRequested); + Assert.Equal(TimeSpan.FromSeconds(2), ActivationInbox.ConnectDeadline); + } + + + + /// A peer that connects and sends nothing is dropped at the exchange deadline, and the next sender is served. + [Fact] + public async Task Listen_PeerThatSendsNothing_IsDroppedAtTheDeadline() + { + string key = NewKey(); + var clock = new ManualTimeProvider(); + var reported = new TaskCompletionSource(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(await reported.Task.WaitAsync(safety.Token)); + } + Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token)); + Assert.Equal(TimeSpan.FromSeconds(10), ActivationInbox.ExchangeDeadline); + } + + + + /// A sender that disconnects without a request is reported and does not block the next one; disposing twice is harmless and frees the name. + [Fact] + public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe() + { + string key = NewKey(); + var errors = new ConcurrentQueue(); + 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)); + } + + + + /// A peer that has already closed when the reader turns to it is released before the next sender connects. + [Fact] + public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect() + { + string key = NewKey(); + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var received = new ConcurrentQueue(); + 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(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); + } + + + + /// A pipe name no other test or process uses. + private static string NewKey() => "k22-mv-tests-" + Guid.NewGuid().ToString("N"); + + + + /// Sends one raw frame and returns the reader's answer byte, or -1 when the reader closed the connection without answering. + private static async Task 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; + } + } + + + + /// Captures the listener's first continuation so a test decides when it resumes. + private sealed class DeferredConnectionContext : SynchronizationContext + { + /// The captured continuation and its state. + internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + + + /// Holds the first posted continuation instead of running it. + public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state)); + } +} diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ActivationRequestTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ActivationRequestTests.cs new file mode 100644 index 0000000..96688df --- /dev/null +++ b/tests/MarkdownViewr.Windows.Core.Tests/ActivationRequestTests.cs @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using MarkdownViewr.Windows.Core.Activation; + + +namespace MarkdownViewr.Windows.Core.Tests; + +/// What one launch may ask the running reader to open, and which launches share a reader. +public sealed class ActivationRequestTests +{ + /// Relative arguments resolve against the launching folder; shell characters stay literal and duplicate paths collapse without regard to case. + [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); + } + + + + /// An argument that cannot name a document is dropped and flagged; the documents beside it still open. + [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(); + } + + + + /// A launch may name at most the bounded number of documents; one more is refused outright. + [Fact] + public void Create_PathCountAtAndPastTheBound_IsBoundedExactly() + { + string directory = Path.GetTempPath(); + IEnumerable 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(() => ActivationRequest.Create(names, directory)); + Assert.Throws(() => ActivationRequest.Create(null!, directory)); + Assert.Equal(128, ActivationRequest.MaximumPaths); + } + + + + /// A request that did not come from Create is refused: relative, empty, control-bearing, overlong or too many paths. + [Fact] + public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests() + { + Assert.Throws(() => new ActivationRequest(null!).Validate()); + Assert.Throws(() => new ActivationRequest(["relative.md"]).Validate()); + Assert.Throws(() => new ActivationRequest([""]).Validate()); + Assert.Throws(() => new ActivationRequest([null!]).Validate()); + Assert.Throws(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate()); + Assert.Throws(() => new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths + 1).ToArray()).Validate()); + new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths).ToArray()).Validate(); + } + + + + /// A path is accepted up to the Windows limit and refused one character past it. + [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(() => new ActivationRequest([atLimit + "a"]).Validate()); + } + + + + /// + /// 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. + /// + [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); + } +} diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ManualTimeProvider.cs b/tests/MarkdownViewr.Windows.Core.Tests/ManualTimeProvider.cs new file mode 100644 index 0000000..0a6b220 --- /dev/null +++ b/tests/MarkdownViewr.Windows.Core.Tests/ManualTimeProvider.cs @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + + +namespace MarkdownViewr.Windows.Core.Tests; + +/// +/// 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. +/// +internal sealed class ManualTimeProvider : TimeProvider +{ + private readonly Lock _gate = new(); + private readonly List _timers = []; + private TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously); + + + + /// Records the timer instead of scheduling it. + 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; + } + + + + /// Completes once code under test has created a timer since the last call. + public Task NextTimerAsync(CancellationToken cancellationToken) + { + Task created; + lock(_gate) + { + created = _created.Task; + if(created.IsCompleted) + { + _created = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + return created.WaitAsync(cancellationToken); + } + + + + /// Fires every timer created so far that has not been disposed, as if each deadline had passed. + public void Elapse() + { + ManualTimer[] pending; + lock(_gate) + { + pending = _timers.ToArray(); + _timers.Clear(); + } + foreach(ManualTimer timer in pending) + { + timer.Fire(); + } + } + + + + /// A timer that runs its callback once, on request, unless it was disposed first. + private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer + { + private int _disposed; + + + + /// Runs the callback unless the owner has already let go of the timer. + public void Fire() + { + if(Volatile.Read(ref _disposed) == 0) + { + callback(state); + } + } + + + + /// Rescheduling is accepted and ignored: only fires a timer. + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + + + /// Marks the timer dead so a later skips it. + public void Dispose() => Interlocked.Exchange(ref _disposed, 1); + + + + /// Asynchronous disposal has nothing to wait for. + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/MarkdownViewr.Windows.UiTests/ActivationInboxTests.cs b/tests/MarkdownViewr.Windows.UiTests/ActivationInboxTests.cs deleted file mode 100644 index 02ae6df..0000000 --- a/tests/MarkdownViewr.Windows.UiTests/ActivationInboxTests.cs +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (c) 2026 James Maes - * SPDX-License-Identifier: MIT - */ - -using System.Buffers.Binary; -using System.Collections.Concurrent; -using System.IO.Pipes; -using System.Text; - - -namespace MarkdownViewr.Windows.UiTests; - -/// Real current-user pipes prove bounded native delivery without taking desktop input or launching an app. -public sealed class ActivationInboxTests -{ - /// A startup request survives until a reader dispatcher attaches; rejected dispatch returns a negative acknowledgement. - [Fact] - public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement() - { - string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N"); - var received = new ConcurrentQueue(); - var errors = new ConcurrentQueue(); - 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); - } - - - - /// Oversized frames, unknown JSON fields and relative pipe paths are rejected, then a real valid peer still connects. - [Fact] - public async Task Listen_MalformedPeers_RejectsAndRecovers() - { - string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N"); - var received = new ConcurrentQueue(); - var errors = new ConcurrentQueue(); - 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); - } - - - - /// A disconnected sender never blocks the next activation and shutdown releases the pipe without leaking an owned connection. - [Fact] - public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe() - { - string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N"); - var errors = new ConcurrentQueue(); - 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)); - } - - - - /// An already-closed peer must release its native connection before another real activation. - [Fact] - public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect() - { - string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N"); - var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var received = new ConcurrentQueue(); - 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(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); - } - - - - /// Defers only the first real server continuation, making the already-closed native peer schedule reproducible. - private sealed class DeferredConnectionContext : SynchronizationContext - { - internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - - - /// Captures the actual asynchronous continuation without changing pipe bytes, handles or acknowledgements. - public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state)); - } - - - - /// Sends an intentionally invalid frame and waits for the actual server to close it before starting the next peer. - 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. - } - } -} diff --git a/tests/MarkdownViewr.Windows.UiTests/ActivationRequestTests.cs b/tests/MarkdownViewr.Windows.UiTests/ActivationRequestTests.cs deleted file mode 100644 index f084576..0000000 --- a/tests/MarkdownViewr.Windows.UiTests/ActivationRequestTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2026 James Maes - * SPDX-License-Identifier: MIT - */ - - -namespace MarkdownViewr.Windows.UiTests; - -/// Sender-relative paths, Unicode, duplicate launches and stable/dev/profile isolation retain their actual meanings. -public sealed class ActivationRequestTests -{ - /// Canonicalization belongs to the sender, including literal shell characters and case-insensitive duplicate paths. - [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); - } - - - - /// Malformed messages cannot open relative paths or allocate an unbounded number of document windows. - [Fact] - public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests() - { - Assert.Throws(() => new ActivationRequest(null!).Validate()); - Assert.Throws(() => new ActivationRequest(["relative.md"]).Validate()); - Assert.Throws(() => new ActivationRequest([""]).Validate()); - Assert.Throws(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate()); - Assert.Throws(() => new ActivationRequest(["C:\\" + new string('a', 32768)]).Validate()); - Assert.Throws(() => ActivationRequest.Create(Enumerable.Repeat("one.md", ActivationRequest.MaximumPaths + 1), Path.GetTempPath())); - } - - - - /// Equivalent directory spellings share an instance, while distinct review profiles and channel installation roots do not. - [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); - } -} diff --git a/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj b/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj index 6aa1a97..304f6e5 100644 --- a/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj +++ b/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj @@ -36,9 +36,6 @@ - - -