Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf7c8132d
|
||
|
|
a1320e489e
|
||
|
|
8763ff3206 | ||
|
|
2038be4355
|
||
|
|
aff40e8857 | ||
|
|
6e69f9619d | ||
|
|
1105a4d076 | ||
|
|
fb83501566 | ||
|
|
42220c0e06 | ||
|
|
135256c9f1 | ||
|
|
4c7406723c | ||
|
|
3275d197e6 | ||
|
|
2e940d078a
|
No files matched your search
@@ -0,0 +1,50 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
windows:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
commands: |
|
||||
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
|
||||
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror --no-restore
|
||||
# The WinUI app project is built directly so the format workspace can load its generated XAML code.
|
||||
dotnet build src/MarkdownViewr.Windows/MarkdownViewr.Windows.csproj -p:Platform=x64 -warnaserror --no-restore
|
||||
$env:Platform = 'x64'
|
||||
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
|
||||
Remove-Item Env:Platform
|
||||
dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --logger trx --results-directory TestResults
|
||||
pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core
|
||||
# PowerShell build scripts: analyzer and Pester boundary tests with pinned modules saved to the job's temp directory.
|
||||
$modules = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "psmodules-$env:GITHUB_RUN_ID"
|
||||
New-Item -ItemType Directory -Force -Path $modules | Out-Null
|
||||
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Import-Module (Join-Path $modules PSScriptAnalyzer/1.25.0/PSScriptAnalyzer.psd1)
|
||||
Import-Module (Join-Path $modules Pester/5.7.1/Pester.psd1)
|
||||
foreach ($file in Get-ChildItem -LiteralPath build -Recurse -File | Where-Object Extension -In '.ps1', '.psm1', '.psd1') {
|
||||
# Casing runs in its own pass: command metadata races other rules in 1.25.0 (PSScriptAnalyzer#1708).
|
||||
$findings = @(
|
||||
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -IncludeRule PSUseCorrectCasing
|
||||
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -ExcludeRule PSUseCorrectCasing
|
||||
)
|
||||
if ($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $($file.Name)" }
|
||||
}
|
||||
# The Pester suites assert on child processes' exit codes themselves.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$pester = New-PesterConfiguration
|
||||
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/GitHubRepository.Tests.ps1', 'build/Distribution.Tests.ps1', 'build/InstalledUpdates.Tests.ps1', 'build/PackageBytes.Tests.ps1', 'build/ReleaseSigning.Tests.ps1')
|
||||
$pester.Run.PassThru = $true
|
||||
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
|
||||
scripts:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '24'
|
||||
install: npm ci --ignore-scripts
|
||||
commands: |
|
||||
npm run lint
|
||||
npm test
|
||||
npm audit signatures
|
||||
@@ -30,7 +30,7 @@ and isolated boundary evidence in ADR-0005. Explorer and installed lifecycle
|
||||
acceptance remain required; literal process arguments alone are not association
|
||||
proof.
|
||||
|
||||
Store at most20 successful absolute paths in the private AppKit settings file,
|
||||
Store at most20 successful absolute paths in the private settings file,
|
||||
deduplicated with Windows path comparison. Update only after a parsed snapshot
|
||||
commits. Clear history removes entries without deleting files or closing readers.
|
||||
Keep paths out of activity logs/telemetry. Each preference save merges only fields
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# ADR-0006: Settings file protocol
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-11-10
|
||||
|
||||
## Context
|
||||
|
||||
One `settings.json` per edition is shared by every reader window and by every
|
||||
process that uses the same data folder: an installed copy and a portable copy, or
|
||||
two builds started from different folders, are separate processes over one file.
|
||||
|
||||
AppKit's `SettingsStore` returns defaults whenever the file cannot be read and writes
|
||||
a whole snapshot through one fixed temporary name with no lock. With real files and
|
||||
real processes this lost data in two ways: a load during a sharing violation returned
|
||||
defaults that the next save wrote back, and two processes saving in turn erased each
|
||||
other's changes.
|
||||
|
||||
The files beside `settings.json` are now a contract between application versions that
|
||||
may run at the same time, so the protocol is recorded here.
|
||||
|
||||
## Decision
|
||||
|
||||
`AppSettingsStore` in the core library owns the file. AppKit still defines the shell
|
||||
preferences (`ShellSettings`); only persistence moved.
|
||||
|
||||
**Change, not snapshot.** `Update` takes a function from the stored settings to the
|
||||
new settings. It runs under the lock against what is on disk at that moment, so a
|
||||
caller cannot write back a stale copy of a value it did not change.
|
||||
|
||||
**Lock.** `settings.json.lock` is opened with no sharing for the duration of an
|
||||
update. It is never deleted: deleting a lock file leaves a window in which a second
|
||||
opener is refused with access denied. The operating system releases the handle if
|
||||
the process dies. A lock that stays held for every attempt refuses the update.
|
||||
|
||||
**Read for an update.** The file is opened for reading while denying writers, and
|
||||
that handle is held until the rename. A program rewriting the file in place therefore
|
||||
reads as busy, not as invalid content, and none can start between the read and the
|
||||
rename. An empty file is waited on for the same attempts, because a program that
|
||||
clears a file before rewriting it leaves it empty for a moment.
|
||||
|
||||
**Read for display.** The same open is tried first. If another handle has write
|
||||
access, the file is read beside it instead of waiting, since such a handle is usually
|
||||
idle. Content read that way which does not parse is retried and, if it never parses,
|
||||
reported as unreadable rather than corrupt: it cannot be judged while it can still be
|
||||
written. An empty file reads as defaults at once.
|
||||
|
||||
**What a read can find.**
|
||||
|
||||
| Found | Used for display | On update |
|
||||
| --- | --- | --- |
|
||||
| No file | Defaults | The file is created |
|
||||
| Empty file | Defaults | Replaced after the wait; nothing kept aside |
|
||||
| Valid settings | The stored values | Replaced |
|
||||
| Some invalid properties | Valid properties kept, invalid ones as defaults | Replaced; a copy of the original is kept aside |
|
||||
| Not settings at all | Defaults | Replaced; a copy of the original is kept aside, unless it is over 16 MiB |
|
||||
| Unreadable or being written | Defaults | Refused; nothing is written |
|
||||
|
||||
A trailing comma, a comment and a quoted number are valid: they are what a hand edit
|
||||
commonly leaves behind. Text the JSON reader cannot decode, such as an unpaired
|
||||
surrogate escape, makes its property invalid. Repair works per top-level property:
|
||||
one bad entry in a list costs that list. A file over 16 MiB is not loaded.
|
||||
|
||||
**Write.** The new content is written to `settings.json.<32 hex digits>.tmp` and
|
||||
flushed to disk. That file is then renamed over `settings.json` in one step with the
|
||||
Windows rename that replaces an open file (`FileRenameInfoEx` with POSIX semantics).
|
||||
At no point is there no `settings.json`: a reader sees the old file or the new one,
|
||||
and a process killed at any moment leaves one of them. Handles that were open on the
|
||||
old file and allow deletion go on reading it. `File.Replace` is not used because it
|
||||
is two renames with the file missing between them, and `File.Move` with overwrite is
|
||||
not used because it refuses while any handle is open on the destination.
|
||||
|
||||
The rename is given the path in the form Windows takes as written, so a data folder
|
||||
whose path is longer than 260 characters, or whose name is not well-formed UTF-16,
|
||||
behaves like any other.
|
||||
|
||||
On a volume that does not implement that rename, the store closes its own read
|
||||
handle and falls back to `File.Move` with overwrite. If the file did not exist when
|
||||
it was read, the temporary file is renamed into place without overwriting. A failed
|
||||
update leaves the stored file as it was and removes its temporary file.
|
||||
|
||||
**Kept-aside copies.** Before invalid content is replaced it is copied to a temporary
|
||||
file. Once the save has succeeded the copy is renamed to `settings.json.corrupt`, or
|
||||
to the first free of `settings.json.corrupt-2` to `settings.json.corrupt-9`; the last
|
||||
name is overwritten once all exist. A save that fails removes its temporary copy and
|
||||
leaves every earlier copy as it was, and the invalid file is still in place. The copy
|
||||
is a courtesy for recovering a hand edit, with these limits:
|
||||
|
||||
- a file over 16 MiB is replaced without a copy, since it was never this
|
||||
application's settings and copying it would stall the window;
|
||||
- a copy that cannot be written, for example on a full disk, or cannot be given its
|
||||
name, is removed and reported, and the save goes ahead, because refusing would
|
||||
block every later change too;
|
||||
- a process killed between the save and the rename of the copy loses that copy.
|
||||
|
||||
Nothing reads the copies back. They contain whatever the file contained, including
|
||||
recent-document paths, so clearing recent files deletes them.
|
||||
|
||||
**Abandoned temporary files.** A process killed between its write and its rename
|
||||
leaves a temporary file, and possibly a temporary copy. The next update, holding the
|
||||
lock, removes files that match this store's own name pattern and no others.
|
||||
|
||||
**Waiting.** Only a sharing violation is waited out: at most 20 attempts, 50 ms
|
||||
apart, for the lock, the read and the rename. Access denied is treated as permanent
|
||||
and reported at once, so a read-only profile does not stall a window. The fallback
|
||||
move is the exception: it answers access denied while any handle is open on the
|
||||
file, a reader's included, so there access denied is waited out as well, unless the
|
||||
file carries the read-only attribute.
|
||||
|
||||
**Reporting.** Faults are reported by kind with the exception type and HRESULT. No
|
||||
path and no operating-system message text reaches the log.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An update is a synchronous call on the window thread. It normally takes a few
|
||||
milliseconds and at most about one second per contended step.
|
||||
- The JSON written is byte-compatible with earlier releases, so a downgrade reads it.
|
||||
- The rename needs Windows 10 version 1607 or later and a file system that supports
|
||||
it; NTFS does. Other volumes use the fallback, which is not safe against a kill
|
||||
between closing the file and the move completing.
|
||||
- An earlier release running against the same data folder still has its own defect:
|
||||
it takes no lock, and when a read fails it falls back to defaults and may save
|
||||
them. This store cannot prevent that. It also makes that release's saves fail more
|
||||
often while both run, because the earlier release cannot replace a file this store
|
||||
has open. Run one version per data folder.
|
||||
- The store keeps no in-memory copy. Windows hold their own view of the settings and
|
||||
merge only the fields they changed (ADR-0004).
|
||||
|
||||
## Options considered
|
||||
|
||||
- **A named mutex instead of a lock file.** Rejected: a session-local name does not
|
||||
cover two sessions of one user on the same profile, and a global name can be
|
||||
created first by another account on the machine.
|
||||
- **Renaming the invalid file away instead of copying it.** Rejected: between that
|
||||
rename and the new file arriving there is no settings file, and a kill there loses
|
||||
the profile.
|
||||
- **Fixing AppKit's store instead.** Still worthwhile for other applications, but the
|
||||
read-modify-write API is a different shape, and this application needed the fix
|
||||
without waiting for a package release.
|
||||
@@ -34,7 +34,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Remote resources | Denied by CSP and resource interception |
|
||||
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
|
||||
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. Settings changes are read-modify-write under a lock file beside `settings.json` and are renamed into place in one step, so the file is never absent; a file that is unreadable or being written by another program is never overwritten, a copy of invalid content is kept aside under a bounded set of names and removed when recent files are cleared, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
|
||||
| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight |
|
||||
| Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token |
|
||||
|
||||
@@ -43,7 +43,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Threat | Mitigation and evidence |
|
||||
| --- | --- |
|
||||
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser malicious-script and reader acceptance passed on 77dd04d |
|
||||
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
|
||||
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access. Reserved device names and forbidden characters are one written-out rule (`WindowsFileNames`), independent of the running Windows version: every device name in any letter case, with any extension, and with spaces before the extension |
|
||||
| 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 |
|
||||
|
||||
@@ -73,6 +73,8 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
|
||||
- **Open in editor is disabled:** hover for the reason, then configure an installed editor in Settings.
|
||||
- **The viewer is not a default option:** use Setup rather than a portable copy, then reopen Windows Default Apps.
|
||||
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
|
||||
- **A setting could not be saved:** another program is using `settings.json`, or the file or folder is read-only. Nothing is overwritten while the file cannot be read; try the change again.
|
||||
- **Settings were edited by hand:** a trailing comma, a comment or a quoted number is accepted; the app rewrites the file in its own format on the next save, so comments are not kept. A syntax error such as a missing comma or quote makes the whole file unreadable: every setting starts from its default, and the next save keeps a copy of your file as described below. A setting whose value is not valid is ignored and the other settings are kept. When either happens, the next save keeps a copy of the file as it was, named `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`). Those copies contain everything the file contained; **File → Recent files → Clear recent files** deletes them.
|
||||
|
||||
For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md).
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"version": "10.0.403",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
@@ -73,7 +72,7 @@ public static class AppIdentity
|
||||
|
||||
|
||||
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
public static AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -202,8 +202,9 @@ public sealed class AppSettings : ShellSettings
|
||||
_ = Themes.ReaderTheme.ParseCompatible(json);
|
||||
return true;
|
||||
}
|
||||
catch(JsonException)
|
||||
catch(Exception exception) when(exception is JsonException or InvalidOperationException)
|
||||
{
|
||||
// A string the JSON reader cannot decode, such as an unpaired surrogate escape, is invalid content too.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.</summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
/// <summary>
|
||||
/// Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.
|
||||
/// Reading tolerates what a hand edit commonly leaves behind: a trailing comma, a comment, a quoted number.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(AppSettings))]
|
||||
public sealed partial class AppSettingsContext : JsonSerializerContext
|
||||
{
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The settings file shared by every window and every process of one edition. A change is
|
||||
/// applied to the latest stored settings under a cross-process lock and renamed into place in
|
||||
/// one step, so two instances cannot erase each other's change and the file is never absent.
|
||||
/// A file that cannot be read, or that another program is writing, is never treated as empty;
|
||||
/// content that is invalid while nobody is writing it is repaired where possible and a copy of
|
||||
/// the original is kept aside. ADR-0006 describes the files involved.
|
||||
/// </summary>
|
||||
public sealed class AppSettingsStore
|
||||
{
|
||||
/// <summary>Attempts for the lock, a read, and the final rename before the operation gives up.</summary>
|
||||
public const int MaximumAttempts = 20;
|
||||
|
||||
/// <summary>A larger file is not this application's settings and is never loaded into memory.</summary>
|
||||
public const int MaximumBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>Copies of invalid files kept aside before the last name is reused.</summary>
|
||||
public const int MaximumKeptAside = 9;
|
||||
|
||||
/// <summary>The pause between attempts while another handle is in the way.</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private const int SharingViolation = unchecked((int)0x80070020);
|
||||
private static readonly JsonDocumentOptions s_lenient = new() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip };
|
||||
private readonly Action<SettingsFault, Exception?> _report;
|
||||
private readonly Action<TimeSpan> _wait;
|
||||
private readonly Func<string, string, bool> _renameOver;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store; faults go to <paramref name="report"/>, and tests supply <paramref name="wait"/> to count attempts instead of sleeping.</summary>
|
||||
public AppSettingsStore(string path, Action<SettingsFault, Exception?>? report = null, Action<TimeSpan>? wait = null)
|
||||
: this(path, report, wait, FileSwap.TryRenameOver)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store over a given rename, so tests can stand in for a volume that cannot rename over an open file.</summary>
|
||||
internal AppSettingsStore(string path, Action<SettingsFault, Exception?>? report, Action<TimeSpan>? wait, Func<string, string, bool> renameOver)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
Path = System.IO.Path.GetFullPath(path);
|
||||
_report = report ?? ((_, _) => { });
|
||||
_wait = wait ?? Thread.Sleep;
|
||||
_renameOver = renameOver;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The settings file, as an absolute path.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the stored settings for display. A missing, empty, invalid or unreadable file yields
|
||||
/// defaults and is left untouched. A handle that another program merely holds with write
|
||||
/// access does not delay the read.
|
||||
/// </summary>
|
||||
public AppSettings Load()
|
||||
{
|
||||
_ = TryRead(forUpdate: false, out AppSettings settings, out _);
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Applies one change to the latest stored settings and saves the result. Returns the saved
|
||||
/// settings, or null when nothing was written: the lock stayed busy, the file could not be
|
||||
/// read or was being written by another program, or the write failed. The stored file is
|
||||
/// unchanged in every null case.
|
||||
/// </summary>
|
||||
public AppSettings? Update(Func<AppSettings, AppSettings> change)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
FileStream? stored = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
|
||||
using FileStream? gate = Acquire();
|
||||
if(gate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// The read handle denies writers until the rename, so the content judged here is the content replaced.
|
||||
SettingsRead read = TryRead(forUpdate: true, out AppSettings current, out stored);
|
||||
if(read == SettingsRead.Unreadable)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
AppSettings edited = change(current);
|
||||
edited.Sanitize();
|
||||
RemoveAbandonedTemporaryFiles();
|
||||
string? copy = read is SettingsRead.Corrupt or SettingsRead.Repaired ? CopyAside(stored!) : null;
|
||||
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored, copy);
|
||||
if(copy is not null)
|
||||
{
|
||||
KeepAside(copy);
|
||||
}
|
||||
return edited;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_report(SettingsFault.WriteFailed, exception);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stored?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the copies of invalid files that saves have kept aside. They hold whatever the
|
||||
/// file held, including the recent-document history, so clearing that history removes them.
|
||||
/// </summary>
|
||||
public void DeleteKeptAside()
|
||||
{
|
||||
for(int slot = 1; slot <= MaximumKeptAside; slot++)
|
||||
{
|
||||
TryDelete(KeptAsideName(slot));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Holds an exclusive handle beside the settings file; the operating system releases it if the
|
||||
/// process dies. A lock that stays held, or cannot be opened for another I/O reason, is busy.
|
||||
/// </summary>
|
||||
private FileStream? Acquire()
|
||||
{
|
||||
try
|
||||
{
|
||||
FileStream? gate = null;
|
||||
Retry(() => gate = new FileStream(Path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None));
|
||||
return gate;
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
_report(SettingsFault.Busy, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the file, waiting out other handles a bounded number of times, and reports what it
|
||||
/// found. For an update the open handle is returned so writers stay out until the rename.
|
||||
/// </summary>
|
||||
private SettingsRead TryRead(bool forUpdate, out AppSettings settings, out FileStream? stored)
|
||||
{
|
||||
settings = new AppSettings();
|
||||
stored = null;
|
||||
SettingsRead? read = null;
|
||||
for(int attempt = 1; read is null; attempt++)
|
||||
{
|
||||
if(attempt > 1)
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
read = Attempt(forUpdate, attempt == MaximumAttempts, ref settings, ref stored);
|
||||
}
|
||||
if(read is SettingsRead.Corrupt or SettingsRead.Repaired)
|
||||
{
|
||||
_report(read == SettingsRead.Corrupt ? SettingsFault.Corrupt : SettingsFault.Repaired, null);
|
||||
}
|
||||
settings.Sanitize();
|
||||
return read.Value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One read. Returns null to try again: another handle is in the way, the file is empty while
|
||||
/// a save is waiting for whoever cleared it, or the content read beside a writer did not parse.
|
||||
/// On the last attempt it always answers.
|
||||
/// </summary>
|
||||
private SettingsRead? Attempt(bool forUpdate, bool last, ref AppSettings settings, ref FileStream? stored)
|
||||
{
|
||||
FileStream? stream = null;
|
||||
bool keep = false;
|
||||
try
|
||||
{
|
||||
stream = Open(forUpdate, out bool writersExcluded);
|
||||
SettingsRead read = Parse(stream, ref settings);
|
||||
if(read == SettingsRead.Empty && forUpdate && !last)
|
||||
{
|
||||
// A program that clears the file before rewriting it is about to write.
|
||||
return null;
|
||||
}
|
||||
if(read == SettingsRead.Corrupt && !writersExcluded)
|
||||
{
|
||||
// Content read while another program can write may simply be half written.
|
||||
return last ? Unreadable(null) : null;
|
||||
}
|
||||
keep = forUpdate;
|
||||
stored = keep ? stream : null;
|
||||
return read;
|
||||
}
|
||||
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return SettingsRead.Missing;
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !last)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Unreadable(exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!keep)
|
||||
{
|
||||
stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports a file whose content could not be established and answers that it must not be replaced.</summary>
|
||||
private SettingsRead Unreadable(Exception? exception)
|
||||
{
|
||||
_report(SettingsFault.Unreadable, exception);
|
||||
return SettingsRead.Unreadable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file denying writers, so a program rewriting it in place reads as busy rather
|
||||
/// than as invalid content. A read for display falls back to reading beside a handle that has
|
||||
/// write access, because such a handle is usually idle; the caller then knows the content may
|
||||
/// be half written.
|
||||
/// </summary>
|
||||
private FileStream Open(bool forUpdate, out bool writersExcluded)
|
||||
{
|
||||
writersExcluded = true;
|
||||
try
|
||||
{
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !forUpdate)
|
||||
{
|
||||
writersExcluded = false;
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads at most <see cref="MaximumBytes"/> and classifies the content; an invalid property costs that property, never the whole profile.</summary>
|
||||
private static SettingsRead Parse(FileStream stream, ref AppSettings settings)
|
||||
{
|
||||
if(stream.Length == 0)
|
||||
{
|
||||
return SettingsRead.Empty;
|
||||
}
|
||||
if(stream.Length > MaximumBytes)
|
||||
{
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
string json = reader.ReadToEnd();
|
||||
try
|
||||
{
|
||||
if(JsonSerializer.Deserialize(json, AppSettingsContext.Default.AppSettings) is { } loaded)
|
||||
{
|
||||
settings = loaded;
|
||||
return SettingsRead.Loaded;
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
if(Salvage(json) is { } repaired)
|
||||
{
|
||||
settings = repaired;
|
||||
return SettingsRead.Repaired;
|
||||
}
|
||||
}
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Keeps every stored property that is valid on its own and drops the ones that are not; null when the file is not a JSON object at all.</summary>
|
||||
private static AppSettings? Salvage(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(JsonNode.Parse(json, documentOptions: s_lenient) is not JsonObject stored)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
foreach(string name in stored.Select(property => property.Key).ToArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new JsonObject { [name] = stored[name]?.DeepClone() }.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
stored.Remove(name);
|
||||
}
|
||||
}
|
||||
return stored.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception) || exception is ArgumentException)
|
||||
{
|
||||
// Not JSON, or an object that repeats a property name.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the JSON reader throws for content that is not valid settings: a syntax or type error,
|
||||
/// or text it cannot decode, such as an unpaired surrogate escape.
|
||||
/// </summary>
|
||||
private static bool IsInvalidContent(Exception exception) => exception is JsonException or InvalidOperationException;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies an invalid file to a temporary name before it is replaced and returns that name.
|
||||
/// The copy is a courtesy, so the save goes ahead without one, with the reason reported and
|
||||
/// null returned, when the file is past the size bound (it was never this application's
|
||||
/// settings) or the copy cannot be written. A copy left behind by a save that never finished
|
||||
/// is removed with the other abandoned temporary files.
|
||||
/// </summary>
|
||||
private string? CopyAside(FileStream stored)
|
||||
{
|
||||
if(stored.Length > MaximumBytes)
|
||||
{
|
||||
_report(SettingsFault.NotKeptAside, null);
|
||||
return null;
|
||||
}
|
||||
string temporary = TemporaryName();
|
||||
try
|
||||
{
|
||||
using(var copy = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stored.Position = 0;
|
||||
stored.CopyTo(copy);
|
||||
copy.Flush(flushToDisk: true);
|
||||
}
|
||||
return temporary;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gives the copy of a replaced invalid file its lasting name: the first free kept-aside name,
|
||||
/// or the last one once all are taken. This happens after the save, so a save that fails
|
||||
/// never disturbs an earlier copy. The copy is a courtesy: one that cannot be placed is
|
||||
/// removed and reported, and the save stands.
|
||||
/// </summary>
|
||||
private void KeepAside(string copy)
|
||||
{
|
||||
int slot = 1;
|
||||
while(slot < MaximumKeptAside && (File.Exists(KeptAsideName(slot)) || Directory.Exists(KeptAsideName(slot))))
|
||||
{
|
||||
slot++;
|
||||
}
|
||||
try
|
||||
{
|
||||
File.Move(copy, KeptAsideName(slot), overwrite: true);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
TryDelete(copy);
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The kept-aside names: <c>.corrupt</c>, then <c>.corrupt-2</c> to <c>.corrupt-9</c>.</summary>
|
||||
private string KeptAsideName(int slot) => slot == 1 ? Path + ".corrupt" : Path + ".corrupt-" + slot.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
|
||||
/// <summary>A name beside the settings file that only this store uses, so an abandoned one can be recognized and removed.</summary>
|
||||
private string TemporaryName() => Path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes beside the file and flushes to disk, then renames over it in one step. A failure
|
||||
/// leaves the stored file as it was, and neither the temporary file nor the copy of the
|
||||
/// invalid original behind.
|
||||
/// </summary>
|
||||
private void Replace(byte[] content, FileStream? stored, string? copy)
|
||||
{
|
||||
string temporary = TemporaryName();
|
||||
bool replaced = false;
|
||||
try
|
||||
{
|
||||
using(var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(content);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if(stored is null)
|
||||
{
|
||||
// No overwrite: a file that appeared since the read is not this instance's to replace.
|
||||
File.Move(temporary, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool renamed = false;
|
||||
Retry(() => renamed = _renameOver(temporary, Path));
|
||||
if(!renamed)
|
||||
{
|
||||
// This volume cannot rename over an open file, so the file is closed first. This
|
||||
// move is refused while any handle is open on the file, a reader's included.
|
||||
stored.Dispose();
|
||||
Retry(() => File.Move(temporary, Path, overwrite: true), IsBlockedByAHandle);
|
||||
}
|
||||
}
|
||||
replaced = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!replaced)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
if(copy is not null)
|
||||
{
|
||||
TryDelete(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A writer that died before its rename left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
|
||||
private void RemoveAbandonedTemporaryFiles()
|
||||
{
|
||||
string prefix = System.IO.Path.GetFileName(Path) + ".";
|
||||
foreach(string file in Directory.EnumerateFiles(System.IO.Path.GetDirectoryName(Path)!, prefix + "*.tmp"))
|
||||
{
|
||||
string name = System.IO.Path.GetFileName(file);
|
||||
if(name.Length == prefix.Length + 36 && Guid.TryParseExact(name.AsSpan(prefix.Length, 32), "N", out _))
|
||||
{
|
||||
TryDelete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Repeats an operation only while another handle is in the way, a bounded number of times;
|
||||
/// the last failure is thrown. <paramref name="inTheWay"/> says which failures mean that:
|
||||
/// a sharing violation unless the operation reports an open handle differently.
|
||||
/// </summary>
|
||||
private void Retry(Action operation, Func<Exception, bool>? inTheWay = null)
|
||||
{
|
||||
inTheWay ??= IsTransient;
|
||||
for(int attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
operation();
|
||||
return;
|
||||
}
|
||||
catch(Exception exception) when(attempt < MaximumAttempts && inTheWay(exception))
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sharing violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
|
||||
private static bool IsTransient(Exception exception) => exception is IOException && exception.HResult == SharingViolation;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a move over the settings file fails with while another handle is open on it. Unlike
|
||||
/// an open or a rename, that move answers access denied, which therefore has to be waited
|
||||
/// out. A read-only file gives the same answer for good, so it is told apart by its attribute.
|
||||
/// </summary>
|
||||
private bool IsBlockedByAHandle(Exception exception)
|
||||
=> IsTransient(exception) || (exception is UnauthorizedAccessException && !new FileInfo(Path).IsReadOnly);
|
||||
|
||||
|
||||
|
||||
/// <summary>Removes a file if it is there and can be removed; a folder with that name, or a file in use, is left.</summary>
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Cleanup only: the operation this was part of has already succeeded or failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,6 @@ public sealed class DocumentResourcePolicy
|
||||
".md", ".markdown", ".mdown", ".mkd", ".txt",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> s_deviceNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³",
|
||||
};
|
||||
|
||||
private readonly string _prefix;
|
||||
|
||||
|
||||
@@ -38,6 +32,16 @@ public sealed class DocumentResourcePolicy
|
||||
|
||||
|
||||
|
||||
/// <summary>The image file types served to the document surface.</summary>
|
||||
internal static IReadOnlyCollection<string> ImageExtensions => s_imageExtensions;
|
||||
|
||||
|
||||
|
||||
/// <summary>The document file types a link may open in a new reader.</summary>
|
||||
internal static IReadOnlyCollection<string> DocumentExtensions => s_documentExtensions;
|
||||
|
||||
|
||||
|
||||
/// <summary>The only top-level document URI the browser host may serve.</summary>
|
||||
public Uri DocumentUri { get; }
|
||||
|
||||
@@ -93,7 +97,7 @@ public sealed class DocumentResourcePolicy
|
||||
private static bool IsSafeSegment(string segment)
|
||||
{
|
||||
return segment.Length > 0 && segment is not ("." or "..") && !segment.EndsWith(' ') && !segment.EndsWith('.') &&
|
||||
!segment.Any(character => char.IsControl(character) || "<>:\\|?*".Contains(character, StringComparison.Ordinal)) &&
|
||||
!s_deviceNames.Contains(segment.Split('.')[0]);
|
||||
!segment.Any(character => char.IsControl(character) || WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal)) &&
|
||||
!WindowsFileNames.IsReservedDeviceName(segment);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,11 @@ public sealed class MarkdownRenderer
|
||||
|
||||
|
||||
|
||||
/// <summary>The configured sanitizer, for comparing its allowlists with an independent list.</summary>
|
||||
internal HtmlSanitizer Policy => _sanitizer;
|
||||
|
||||
|
||||
|
||||
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
|
||||
public string Render(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide) => Render(markdown, frontmatter, null);
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a file by renaming another over it in a single step. The destination is never
|
||||
/// absent, not even for an instant, and the rename works while the destination is open through
|
||||
/// handles that allow deletion; those handles go on reading the content they opened.
|
||||
/// <see cref="File.Move(string, string, bool)"/> refuses when any handle is open on the
|
||||
/// destination, and <see cref="File.Replace(string, string, string?)"/> is two renames with the
|
||||
/// destination missing between them, so neither can do this.
|
||||
/// </summary>
|
||||
internal static partial class FileSwap
|
||||
{
|
||||
private const uint DeleteAccess = 0x00010000;
|
||||
private const uint ShareAll = 0x00000007;
|
||||
private const uint OpenExisting = 3;
|
||||
private const int FileRenameInfoEx = 22;
|
||||
private const uint ReplaceIfExists = 0x1;
|
||||
private const uint PosixSemantics = 0x2;
|
||||
private const int AccessDenied = 5;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renames <paramref name="source"/> over <paramref name="destination"/>. Returns false when the
|
||||
/// volume cannot rename over an open file, as file systems other than NTFS may not. Throws
|
||||
/// <see cref="IOException"/> with the Windows error as its HRESULT when the rename is refused,
|
||||
/// for example by a handle on the destination that does not allow deletion, and
|
||||
/// <see cref="UnauthorizedAccessException"/> when access is denied.
|
||||
/// </summary>
|
||||
public static bool TryRenameOver(string source, string destination)
|
||||
{
|
||||
using SafeFileHandle handle = CreateFile(Verbatim(source), DeleteAccess, ShareAll, 0, OpenExisting, 0, 0);
|
||||
if(handle.IsInvalid)
|
||||
{
|
||||
throw Failure(Marshal.GetLastPInvokeError());
|
||||
}
|
||||
// FILE_RENAME_INFO: flags, root directory handle, name length in bytes, then the name itself.
|
||||
// The name is copied as stored: a file name may hold text that is not well-formed UTF-16.
|
||||
byte[] name = MemoryMarshal.AsBytes(Verbatim(destination).AsSpan()).ToArray();
|
||||
int lengthOffset = 2 * IntPtr.Size;
|
||||
int nameOffset = lengthOffset + sizeof(int);
|
||||
byte[] rename = new byte[nameOffset + name.Length + sizeof(char)];
|
||||
BitConverter.TryWriteBytes(rename, ReplaceIfExists | PosixSemantics);
|
||||
BitConverter.TryWriteBytes(rename.AsSpan(lengthOffset), name.Length);
|
||||
name.CopyTo(rename, nameOffset);
|
||||
if(SetFileInformationByHandle(handle, FileRenameInfoEx, rename, rename.Length))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
return IsUnsupported(error) ? false : throw Failure(error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The form of a full path that Windows takes as written, without the 260-character limit the
|
||||
/// plain form has in a process that has not opted in to long paths. The managed file calls add
|
||||
/// it themselves; a direct Windows call has to.
|
||||
/// </summary>
|
||||
internal static string Verbatim(string fullPath)
|
||||
{
|
||||
if(fullPath.StartsWith(@"\\?\", StringComparison.Ordinal) || fullPath.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
return fullPath.StartsWith(@"\\", StringComparison.Ordinal) ? string.Concat(@"\\?\UNC\", fullPath.AsSpan(2)) : @"\\?\" + fullPath;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The errors a volume answers with when it does not implement this kind of rename: invalid function, not supported, invalid parameter.</summary>
|
||||
internal static bool IsUnsupported(int error) => error is 1 or 50 or 87;
|
||||
|
||||
|
||||
|
||||
/// <summary>The exception a managed file operation would throw for the same Windows error.</summary>
|
||||
private static Exception Failure(int error)
|
||||
{
|
||||
string message = new Win32Exception(error).Message;
|
||||
return error == AccessDenied ? new UnauthorizedAccessException(message) : new IOException(message, unchecked((int)0x80070000) | error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens the file to be renamed with delete access, which a managed file stream cannot request.</summary>
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
private static partial SafeFileHandle CreateFile(string path, uint access, uint share, nint security, uint disposition, uint flags, nint template);
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the rename to the open file.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, byte[] information, int size);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<!-- Include the SDK tool dependency in the lock used by single-file CLI publishing. -->
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<!-- Source-generated P/Invoke marshalling (FileSwap) compiles to unsafe code. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>Why the settings file could not be used as stored; reported without paths or operating-system message text.</summary>
|
||||
public enum SettingsFault
|
||||
{
|
||||
/// <summary>The file does not hold settings at all; defaults are used and the next save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>Some stored properties were invalid and read as their defaults; the next save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>The file could not be read, or another program was writing it; it is left untouched and nothing is written over it.</summary>
|
||||
Unreadable,
|
||||
/// <summary>Another instance kept the settings lock for every attempt; the change was not saved.</summary>
|
||||
Busy,
|
||||
/// <summary>The change could not be written; the stored file is unchanged.</summary>
|
||||
WriteFailed,
|
||||
/// <summary>The invalid original could not be copied aside; the change was saved without the copy.</summary>
|
||||
NotKeptAside,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>What reading the settings file found; only <see cref="Unreadable"/> forbids writing over it.</summary>
|
||||
internal enum SettingsRead
|
||||
{
|
||||
/// <summary>Valid stored settings.</summary>
|
||||
Loaded,
|
||||
/// <summary>No file yet; defaults apply and the first save creates it.</summary>
|
||||
Missing,
|
||||
/// <summary>A file with no content, as a program that clears a file before rewriting it leaves it; defaults apply and a save replaces it.</summary>
|
||||
Empty,
|
||||
/// <summary>Valid properties were kept and invalid ones dropped; a save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>Not settings at all; defaults apply and a save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>The content is unknown, so it must not be replaced.</summary>
|
||||
Unreadable,
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -15,8 +16,24 @@ namespace MarkdownViewr.Windows.Core.Themes;
|
||||
/// <summary>Validates Darin's version-one theme format before generating passive document CSS.</summary>
|
||||
public sealed class ReaderTheme
|
||||
{
|
||||
/// <summary>Bounds schema input before a native file read allocates the complete JSON text.</summary>
|
||||
/// <summary>The largest theme file accepted for import; bounds the read before the complete JSON text is allocated.</summary>
|
||||
public const int MaximumCharacters = 128 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The largest theme the app keeps. Renaming or editing rewrites the whole theme, and JSON
|
||||
/// writes some characters (emoji, no-break spaces, private-use glyphs) as six-character
|
||||
/// escapes, so a kept theme can be several times the size of the file it was imported from.
|
||||
/// The bound leaves room for that, so a theme that could be imported can always be renamed.
|
||||
/// </summary>
|
||||
public const int MaximumStoredCharacters = 8 * MaximumCharacters;
|
||||
|
||||
/// <summary>
|
||||
/// How a renamed or edited theme is written back. Quote marks, angle brackets and most
|
||||
/// non-ASCII letters are written as themselves instead of as six-character escapes, which
|
||||
/// keeps ordinary CSS and text close to its imported size. Theme JSON is stored and exported,
|
||||
/// never placed in a page.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions s_compact = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
private static readonly string[] s_colors = ["background", "text", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "link", "codeBackground", "codeText", "blockquoteBorder", "blockquoteBackground", "highlightBackground", "highlightText"];
|
||||
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
|
||||
private static readonly string s_baseStyles = LoadBaseStyles();
|
||||
@@ -59,7 +76,7 @@ public sealed class ReaderTheme
|
||||
{
|
||||
JsonNode source = JsonNode.Parse(Json)!;
|
||||
source["name"] = name;
|
||||
return ParseCompatible(source.ToJsonString());
|
||||
return ParseCompatible(source.ToJsonString(s_compact));
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +120,7 @@ public sealed class ReaderTheme
|
||||
}
|
||||
source["colors"]![key] = value?.Trim();
|
||||
}
|
||||
return ParseCompatible(source.ToJsonString());
|
||||
return ParseCompatible(source.ToJsonString(s_compact));
|
||||
}
|
||||
|
||||
|
||||
@@ -150,13 +167,14 @@ public sealed class ReaderTheme
|
||||
private static ReaderTheme ParseSchema(string json, bool allowNewer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(json);
|
||||
if(json.Length > MaximumCharacters)
|
||||
if(json.Length > MaximumStoredCharacters)
|
||||
{
|
||||
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
|
||||
throw new JsonException("The theme is too large.");
|
||||
}
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
using JsonDocument document = ParseDocument(json);
|
||||
JsonElement root = document.RootElement;
|
||||
RequireObject(root);
|
||||
RequireReadableText(root);
|
||||
int schemaVersion = 1;
|
||||
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out schemaVersion) || schemaVersion < 1 || !allowNewer && schemaVersion != 1))
|
||||
{
|
||||
@@ -278,6 +296,68 @@ public sealed class ReaderTheme
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the theme text as JSON; text that is not well-formed UTF-16 is an invalid theme like any other malformed input.</summary>
|
||||
private static JsonDocument ParseDocument(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
}
|
||||
catch(ArgumentException exception)
|
||||
{
|
||||
throw new JsonException("The theme contains text that cannot be decoded.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks every property name and string in the theme once, including ones this version does
|
||||
/// not read, because a kept theme is later renamed, edited and re-serialized as a whole. An
|
||||
/// unpaired surrogate escape is valid JSON syntax with no string value, and a repeated
|
||||
/// property name has no single value; both are invalid themes.
|
||||
/// </summary>
|
||||
private static void RequireReadableText(JsonElement root)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pending = new Stack<JsonElement>();
|
||||
pending.Push(root);
|
||||
while(pending.TryPop(out JsonElement element))
|
||||
{
|
||||
if(element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach(JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if(!names.Add(property.Name))
|
||||
{
|
||||
throw new JsonException("The theme repeats a property: " + property.Name);
|
||||
}
|
||||
pending.Push(property.Value);
|
||||
}
|
||||
}
|
||||
else if(element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach(JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
pending.Push(item);
|
||||
}
|
||||
}
|
||||
else if(element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_ = element.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(InvalidOperationException exception)
|
||||
{
|
||||
throw new JsonException("The theme contains text that cannot be decoded.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Prevents wrong-shaped JSON from reaching property accessors.</summary>
|
||||
private static void RequireObject(JsonElement element)
|
||||
{
|
||||
|
||||
@@ -33,14 +33,13 @@ public static class ThemeJson
|
||||
public static string SuggestedFileName(ReaderTheme theme)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(theme);
|
||||
string stem = string.Concat(theme.Name.Select(character => "<>:\"/\\|?*".Contains(character, StringComparison.Ordinal) ? '_' : character))
|
||||
string stem = string.Concat(theme.Name.Select(character => WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal) ? '_' : character))
|
||||
.Replace(' ', '-').ToLowerInvariant().Trim('.', '-');
|
||||
if(stem.Length == 0)
|
||||
{
|
||||
stem = "theme";
|
||||
}
|
||||
string device = stem.Split('.')[0];
|
||||
if(device is "con" or "prn" or "aux" or "nul" || device.Length == 4 && (device.StartsWith("com", StringComparison.Ordinal) || device.StartsWith("lpt", StringComparison.Ordinal)) && device[3] is >= '1' and <= '9' or '¹' or '²' or '³')
|
||||
if(WindowsFileNames.IsReservedDeviceName(stem))
|
||||
{
|
||||
stem = "theme-" + stem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The Windows file-name rules this application relies on, written out instead of asked of the
|
||||
/// running platform: the characters a name cannot contain and the names that address a device.
|
||||
/// </summary>
|
||||
public static class WindowsFileNames
|
||||
{
|
||||
/// <summary>Printable characters that cannot appear in a Windows file or folder name. Control characters are also forbidden; callers refuse those themselves.</summary>
|
||||
public const string InvalidCharacters = "<>:\"/\\|?*";
|
||||
|
||||
private static readonly HashSet<string> s_devices = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
|
||||
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
|
||||
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether a single name addresses a device under the legacy Win32 rule: any letter case, and
|
||||
/// ignoring everything after the first dot and any spaces before it, so <c>nul.txt</c> and
|
||||
/// <c>COM1 .log</c> count. Current Windows versions resolve fewer of these spellings to a
|
||||
/// device; the rule is applied in full on every version so the answer never depends on it.
|
||||
/// </summary>
|
||||
public static bool IsReservedDeviceName(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
int dot = name.IndexOf('.', StringComparison.Ordinal);
|
||||
return s_devices.Contains((dot < 0 ? name : name[..dot]).TrimEnd(' '));
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.12, )",
|
||||
"resolved": "10.0.12",
|
||||
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
|
||||
"requested": "[10.0.14, )",
|
||||
"resolved": "10.0.14",
|
||||
"contentHash": "YlTamXppEQls6DYWt7RdUuwWDb0lyw/vXCaGeQpLCpsLmfwGuCkOz/vIGgMTV7ECsJcavkCOkxZXv9s8rdAHkQ=="
|
||||
},
|
||||
"AngleSharp": {
|
||||
"type": "Transitive",
|
||||
|
||||
@@ -107,12 +107,14 @@ public partial class App
|
||||
|
||||
|
||||
|
||||
/// <summary>Records a successfully committed path through the latest store snapshot, then refreshes every recent menu.</summary>
|
||||
/// <summary>Records a successfully committed path in the latest stored history under the settings lock, then refreshes every recent menu.</summary>
|
||||
internal bool RecordRecentDocument(string path)
|
||||
{
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
|
||||
bool saved = AppIdentity.SettingsStore.Save(settings);
|
||||
bool saved = AppIdentity.SettingsStore.Update(settings =>
|
||||
{
|
||||
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
|
||||
return settings;
|
||||
}) is not null;
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
@@ -122,9 +124,16 @@ public partial class App
|
||||
/// <summary>Clears only the local history, leaving open documents, reading preferences and files intact.</summary>
|
||||
internal bool ClearRecentDocuments()
|
||||
{
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.RecentFiles = [];
|
||||
bool saved = AppIdentity.SettingsStore.Save(settings);
|
||||
bool saved = AppIdentity.SettingsStore.Update(settings =>
|
||||
{
|
||||
settings.RecentFiles = [];
|
||||
return settings;
|
||||
}) is not null;
|
||||
if(saved)
|
||||
{
|
||||
// Copies of an invalid settings file kept beside it would still list the history.
|
||||
AppIdentity.SettingsStore.DeleteKeptAside();
|
||||
}
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
using Velopack.Locators;
|
||||
|
||||
|
||||
@@ -31,8 +30,8 @@ internal static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>Each edition owns its own persisted preferences and recent document list.</summary>
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
/// <summary>Each edition owns its own persisted preferences and recent document list; faults are logged by kind, never with a path or message text.</summary>
|
||||
public static Core.AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile, ReportSettingsFault);
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +55,12 @@ internal static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>A settings fault is an operational event: the kind, exception type and HRESULT identify it without disclosing the profile.</summary>
|
||||
private static void ReportSettingsFault(Core.SettingsFault fault, Exception? exception)
|
||||
=> App.Log.Warning(FormattableString.Invariant($"Settings file fault: {fault}; exception={exception?.GetType().Name ?? "none"}; HRESULT=0x{exception?.HResult ?? 0:X8}."));
|
||||
|
||||
|
||||
|
||||
/// <summary>Daily activity files use the edition's own path and identity prefix.</summary>
|
||||
public static IActivityLog CreateLog() => FileActivityLog.ForApp(Info, Paths);
|
||||
}
|
||||
@@ -48,8 +48,8 @@ public sealed partial class MainWindow
|
||||
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
|
||||
draft.ConfiguredEditors = choices;
|
||||
draft.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
|
||||
if(merged is null)
|
||||
{
|
||||
StatusText.Text = "Could not save editor configuration.";
|
||||
return;
|
||||
|
||||
@@ -19,9 +19,12 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.Print = options.Sanitize();
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.Print = options.Sanitize();
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ public sealed partial class MainWindow
|
||||
draft.ContentWidthEnabled = appearance.LimitWidth;
|
||||
draft.ContentWidthPx = appearance.WidthPixels;
|
||||
draft.GlobalCss = appearance.GlobalCss;
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
|
||||
if(merged is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -79,14 +79,16 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.ShowSplashScreen = showSplash;
|
||||
settings.SplashMilliseconds = milliseconds;
|
||||
settings.CheckForUpdatesOnStartup = checkUpdates;
|
||||
settings.ShowOutlineByDefault = showOutline;
|
||||
settings.ShowSourceByDefault = showSource;
|
||||
settings.Sanitize();
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.ShowSplashScreen = showSplash;
|
||||
current.SplashMilliseconds = milliseconds;
|
||||
current.CheckForUpdatesOnStartup = checkUpdates;
|
||||
current.ShowOutlineByDefault = showOutline;
|
||||
current.ShowSourceByDefault = showSource;
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -120,10 +122,13 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.WrapOutlineEntries = wrap;
|
||||
settings.ShowOutlineDepthMarkers = depthMarkers;
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.WrapOutlineEntries = wrap;
|
||||
current.ShowOutlineDepthMarkers = depthMarkers;
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ public sealed partial class MainWindow
|
||||
/// <summary>Failed persistence does not apply an unsaved theme draft; sibling fields untouched by this edit retain their latest values.</summary>
|
||||
private async Task<bool> CommitThemeDraftAsync(Core.AppSettings before, Core.AppSettings edited)
|
||||
{
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, edited);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, edited));
|
||||
if(merged is null)
|
||||
{
|
||||
StatusText.Text = "Could not save document themes.";
|
||||
return false;
|
||||
|
||||
@@ -522,8 +522,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
|
||||
private void Save()
|
||||
{
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), _lastSavedSettings, _settings);
|
||||
if(AppIdentity.SettingsStore.Save(merged))
|
||||
if(AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, _lastSavedSettings, _settings)) is not null)
|
||||
{
|
||||
_lastSavedSettings = Core.SettingsChanges.Copy(_settings);
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ public class AppSettingsTests
|
||||
public void SaveAndLoad_KeepsTheTheme()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
Assert.True(store.Save(new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
|
||||
var store = new AppSettingsStore(temp.File("settings.json"));
|
||||
Assert.NotNull(store.Update(_ => new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
Assert.Equal("GitHub Light", store.Load().DocumentTheme);
|
||||
Assert.Equal(Documents.FrontmatterMode.Metadata, store.Load().FrontmatterKind);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Each resource-policy check is exercised alone: a request that differs from a valid one in a
|
||||
/// single component must be denied by that component's check, not by an unrelated one.
|
||||
/// </summary>
|
||||
public sealed class DocumentResourcePolicyBoundaryTests
|
||||
{
|
||||
/// <summary>The valid baseline every single-component variation below is derived from.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_OwnDocumentRequest_IsAccepted()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal("image.png", policy.GetImageRelativePath(Vary(policy, "image.png", _ => { })));
|
||||
Assert.Equal("child.md", policy.GetDocumentRelativePath(Vary(policy, "child.md", _ => { })));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Plain HTTP on the same host, port and path is not the document's origin.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_OtherScheme_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Uri image = Vary(policy, "image.png", builder =>
|
||||
{
|
||||
builder.Scheme = "http";
|
||||
builder.Port = 443;
|
||||
});
|
||||
Assert.Equal(443, image.Port);
|
||||
Assert.Null(policy.GetImageRelativePath(image));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
|
||||
{
|
||||
builder.Scheme = "http";
|
||||
builder.Port = 443;
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Another port on the same host is another origin.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_OtherPort_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Port = 444)));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Port = 8443)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Credentials in the request never reach a resource.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_UserInformation_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.UserName = "user")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
|
||||
{
|
||||
builder.UserName = "user";
|
||||
builder.Password = "secret";
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Another host is denied, including a subdomain of the viewer's host and a host that merely starts with it.</summary>
|
||||
[Theory]
|
||||
[InlineData("example.com")]
|
||||
[InlineData("sub.viewer.markdownviewr.invalid")]
|
||||
[InlineData("viewer.markdownviewr.invalid.example.com")]
|
||||
public void GetRelativePath_OtherHost_IsDenied(string host)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Host = host)));
|
||||
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Host = host)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The served image types and the openable document types are exactly these sets.</summary>
|
||||
[Fact]
|
||||
public void Extensions_ConfiguredSets_AreExactlyTheListedTypes()
|
||||
{
|
||||
Assert.Equal([".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"], DocumentResourcePolicy.ImageExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Equal([".markdown", ".md", ".mdown", ".mkd", ".txt"], DocumentResourcePolicy.DocumentExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The document's own path prefix is matched exactly; another letter case is another document.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_PrefixInAnotherLetterCase_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
string shouted = policy.DocumentUri.AbsoluteUri.Replace("/documents/", "/DOCUMENTS/", StringComparison.Ordinal);
|
||||
Assert.NotEqual(policy.DocumentUri.AbsoluteUri, shouted, StringComparer.Ordinal);
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(new Uri(shouted), "image.png")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(new Uri(shouted), "child.md")));
|
||||
Assert.Null(policy.GetHeadingIndex(new Uri(shouted + "#mv-heading-0"), 3));
|
||||
Assert.Equal(0, policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + "#mv-heading-0"), 3));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Image types are served in any letter case; documents, pages, scripts and executables are not images.</summary>
|
||||
[Theory]
|
||||
[InlineData(".png", true)]
|
||||
[InlineData(".jpg", true)]
|
||||
[InlineData(".jpeg", true)]
|
||||
[InlineData(".gif", true)]
|
||||
[InlineData(".bmp", true)]
|
||||
[InlineData(".webp", true)]
|
||||
[InlineData(".svg", true)]
|
||||
[InlineData(".avif", true)]
|
||||
[InlineData(".ico", true)]
|
||||
[InlineData(".PNG", true)]
|
||||
[InlineData(".md", false)]
|
||||
[InlineData(".markdown", false)]
|
||||
[InlineData(".txt", false)]
|
||||
[InlineData(".html", false)]
|
||||
[InlineData(".htm", false)]
|
||||
[InlineData(".js", false)]
|
||||
[InlineData(".css", false)]
|
||||
[InlineData(".json", false)]
|
||||
[InlineData(".pdf", false)]
|
||||
[InlineData(".tiff", false)]
|
||||
[InlineData(".exe", false)]
|
||||
[InlineData(".lnk", false)]
|
||||
[InlineData(".url", false)]
|
||||
[InlineData("", false)]
|
||||
public void GetImageRelativePath_Extension_ServesOnlyImageTypes(string extension, bool served)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal(served ? "file" + extension : null, policy.GetImageRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Document types open in a new reader in any letter case; pages, images, scripts and executables do not.</summary>
|
||||
[Theory]
|
||||
[InlineData(".md", true)]
|
||||
[InlineData(".markdown", true)]
|
||||
[InlineData(".mdown", true)]
|
||||
[InlineData(".mkd", true)]
|
||||
[InlineData(".txt", true)]
|
||||
[InlineData(".MD", true)]
|
||||
[InlineData(".html", false)]
|
||||
[InlineData(".htm", false)]
|
||||
[InlineData(".png", false)]
|
||||
[InlineData(".svg", false)]
|
||||
[InlineData(".json", false)]
|
||||
[InlineData(".pdf", false)]
|
||||
[InlineData(".exe", false)]
|
||||
[InlineData(".bat", false)]
|
||||
[InlineData(".cmd", false)]
|
||||
[InlineData(".ps1", false)]
|
||||
[InlineData(".lnk", false)]
|
||||
[InlineData(".url", false)]
|
||||
[InlineData(".mdx", false)]
|
||||
[InlineData("", false)]
|
||||
public void GetDocumentRelativePath_Extension_OpensOnlyDocumentTypes(string extension, bool opened)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal(opened ? "file" + extension : null, policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Each character Windows forbids in a name is refused on its own, for images and documents.</summary>
|
||||
[Theory]
|
||||
[InlineData("%3C")]
|
||||
[InlineData("%3E")]
|
||||
[InlineData("%3A")]
|
||||
[InlineData("%22")]
|
||||
[InlineData("%5C")]
|
||||
[InlineData("%7C")]
|
||||
[InlineData("%3F")]
|
||||
[InlineData("%2A")]
|
||||
public void GetRelativePath_ForbiddenCharacter_IsDenied(string encoded)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "images/a" + encoded + "b.png")));
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "im" + encoded + "ages/ab.png")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/a" + encoded + "b.md")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The generated heading fragment is matched exactly; another letter case is not a heading target.</summary>
|
||||
[Theory]
|
||||
[InlineData("#MV-HEADING-0")]
|
||||
[InlineData("#Mv-Heading-0")]
|
||||
public void GetHeadingIndex_OtherSpelling_IsNotAHeadingTarget(string fragment)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + fragment), 3));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds this document's own resource request and changes exactly one component of it.</summary>
|
||||
private static Uri Vary(DocumentResourcePolicy policy, string file, Action<UriBuilder> change)
|
||||
{
|
||||
var builder = new UriBuilder(new Uri(policy.DocumentUri, file));
|
||||
change(builder);
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
@@ -76,21 +76,24 @@ public sealed class EditorConfigurationTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Atomic AppKit persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
|
||||
/// <summary>Atomic persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
|
||||
[Fact]
|
||||
public void SaveAndLoad_EditorList_PreservesIdentityOrderAndMode()
|
||||
{
|
||||
using var temporary = new TempDirectory();
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temporary.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
var store = new AppSettingsStore(temporary.File("settings.json"));
|
||||
var first = new EditorConfiguration(Guid.NewGuid(), "Folder editor", @"C:\Editor\folder.exe", true);
|
||||
var second = new EditorConfiguration(Guid.NewGuid(), "File editor", @"C:\Editor\file.exe");
|
||||
Assert.True(store.Save(new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
|
||||
Assert.NotNull(store.Update(_ => new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
|
||||
AppSettings loaded = store.Load();
|
||||
Assert.Equal(new[] { first, second }, loaded.ConfiguredEditors);
|
||||
Assert.Equal(first.Executable, loaded.ExternalEditor);
|
||||
loaded.ConfiguredEditors = [];
|
||||
loaded.ExternalEditor = "";
|
||||
Assert.True(store.Save(loaded));
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.ConfiguredEditors = [];
|
||||
settings.ExternalEditor = "";
|
||||
return settings;
|
||||
}));
|
||||
Assert.Empty(store.Load().ConfiguredEditors);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Renaming one real file over another, with other real handles open on the destination.</summary>
|
||||
public sealed class FileSwapTests
|
||||
{
|
||||
/// <summary>A full path is given the form Windows takes as written: a drive path, a network path, and paths that already have it.</summary>
|
||||
[Theory]
|
||||
[InlineData(@"C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
||||
[InlineData(@"\\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
||||
[InlineData(@"\\?\C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
||||
[InlineData(@"\\?\UNC\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
||||
[InlineData(@"\\.\C:\data\settings.json", @"\\.\C:\data\settings.json")]
|
||||
public void Verbatim_FullPath_IsPrefixedOnce(string path, string expected)
|
||||
{
|
||||
Assert.Equal(expected, FileSwap.Verbatim(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The rename replaces the destination and removes the source name.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ExistingDestination_ReplacesIt()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.False(File.Exists(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A reader that allows deletion does not stop the rename, and goes on reading the content it opened.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenByAReaderThatAllowsDeletion_ReplacesItUnderTheReader()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old content");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(var reader = new StreamReader(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)))
|
||||
{
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("old content", reader.ReadToEnd());
|
||||
}
|
||||
Assert.Equal(["stored.json"], Directory.GetFiles(temp.Path).Select(file => Path.GetFileName(file)!), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A handle that does not allow deletion refuses the rename with a sharing violation, and both files stay as they were.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenWithoutDeleteSharing_IsASharingViolation()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
IOException refused = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070020), refused.HResult);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A read-only destination is access denied, as it is for a managed move.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ReadOnlyDestination_IsAccessDenied()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.ReadOnly);
|
||||
try
|
||||
{
|
||||
Assert.Throws<UnauthorizedAccessException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.Normal);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A source that does not exist is reported with the Windows error for a missing file.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_MissingSource_IsAnIoFailure()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
IOException missing = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("absent.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070002), missing.HResult);
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A destination that does not exist yet is simply created by the rename.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_NoDestinationYet_RenamesTheSource()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the errors a volume gives for an unimplemented rename mean "use another way"; every other error is a failure.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(50, true)]
|
||||
[InlineData(87, true)]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(5, false)]
|
||||
[InlineData(32, false)]
|
||||
[InlineData(17, false)]
|
||||
public void IsUnsupported_WindowsError_SeparatesAnUnimplementedRenameFromAFailure(int error, bool unsupported)
|
||||
=> Assert.Equal(unsupported, FileSwap.IsUnsupported(error));
|
||||
}
|
||||
@@ -124,12 +124,15 @@ public sealed class ReaderThemeTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Null and oversized input are rejected before parser work.</summary>
|
||||
/// <summary>Null input is refused, and a theme is kept up to the stored size limit exactly and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void Parse_OutsideSizeContract_RejectsInput()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => ReaderTheme.Parse(null!));
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(new string(' ', 128 * 1024 + 1)));
|
||||
string valid = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Equal("Nord", ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters)).Name);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters + 1)));
|
||||
Assert.Equal(8 * 128 * 1024, ReaderTheme.MaximumStoredCharacters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using Ganss.Xss;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The elements, attributes and link schemes authored HTML may keep. The configured policy is
|
||||
/// compared with the lists below as sets, so any change to it has to be made here as well, and
|
||||
/// the rendering tests show the policy doing what the lists say.
|
||||
/// </summary>
|
||||
public sealed class SanitizerAllowlistTests
|
||||
{
|
||||
private static readonly string[] s_allowedTags =
|
||||
[
|
||||
"a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "ins", "kbd", "li", "mark", "ol", "p", "pre",
|
||||
"s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
||||
];
|
||||
|
||||
private static readonly string[] s_otherTags =
|
||||
[
|
||||
"acronym", "address", "applet", "area", "article", "aside", "audio", "base", "basefont", "bdi", "bdo", "bgsound", "big",
|
||||
"blink", "button", "canvas", "center", "cite", "data", "datalist", "dfn", "dialog", "dir", "embed", "fencedframe",
|
||||
"fieldset", "figcaption", "figure", "font", "footer", "form", "header", "hgroup", "iframe", "keygen", "label", "legend",
|
||||
"link", "listing", "main", "map", "marquee", "math", "menu", "menuitem", "meta", "meter", "nav", "nobr", "noembed",
|
||||
"noframes", "noscript", "object", "optgroup", "option", "output", "param", "picture", "plaintext", "portal", "progress",
|
||||
"q", "rb", "rp", "rt", "rtc", "ruby", "samp", "script", "search", "section", "select", "selectedcontent", "slot", "source",
|
||||
"strike", "style", "svg", "template", "textarea", "time", "title", "track", "tt", "u", "var", "video", "wbr", "xmp",
|
||||
"custom-element",
|
||||
];
|
||||
|
||||
private static readonly string[] s_allowedAttributes =
|
||||
[
|
||||
"align", "alt", "checked", "class", "colspan", "disabled", "height", "href", "rowspan", "src", "title", "type", "width",
|
||||
];
|
||||
|
||||
private static readonly string[] s_otherAttributes =
|
||||
[
|
||||
"accesskey", "action", "allow", "as", "autocapitalize", "autofocus", "autoplay", "background", "bgcolor", "border",
|
||||
"cellpadding", "cellspacing", "charset", "cite", "color", "content", "contenteditable", "controls", "coords",
|
||||
"crossorigin", "data", "data-mv-body", "data-mv-source-fixed", "data-x", "datetime", "dir", "download", "draggable",
|
||||
"enctype", "enterkeyhint", "face", "for", "form", "formaction", "formmethod", "formtarget", "headers", "hidden",
|
||||
"http-equiv", "id", "inert", "inputmode", "integrity", "is", "ismap", "itemprop", "label", "lang", "list", "loading",
|
||||
"longdesc", "loop", "lowsrc", "manifest", "max", "maxlength", "media", "method", "min", "multiple", "muted", "name",
|
||||
"nonce", "novalidate", "nowrap", "onclick", "onerror", "onfocus", "onload", "onmouseover", "onpointerdown", "ontoggle",
|
||||
"onwheel", "open", "part", "pattern", "ping", "placeholder", "popover", "popovertarget", "poster", "preload", "readonly",
|
||||
"referrerpolicy", "rel", "required", "reversed", "role", "sandbox", "scope", "selected", "shape", "size", "sizes", "slot",
|
||||
"span", "spellcheck", "srcdoc", "srclang", "srcset", "start", "step", "tabindex", "target", "translate", "usemap",
|
||||
"valign", "value", "wrap", "xlink:href", "xml:lang", "xmlns", "aria-label",
|
||||
];
|
||||
|
||||
|
||||
|
||||
/// <summary>The policy the renderer configures is exactly these elements, attributes and schemes, with no styling or data attributes.</summary>
|
||||
[Fact]
|
||||
public void Constructor_ConfiguredPolicy_AllowsExactlyTheListedMarkup()
|
||||
{
|
||||
HtmlSanitizer policy = new MarkdownRenderer().Policy;
|
||||
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), policy.AllowedTags.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Equal(s_allowedAttributes.Order(StringComparer.Ordinal), policy.AllowedAttributes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Equal(["http", "https", "mailto"], policy.AllowedSchemes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Empty(policy.AllowedCssProperties);
|
||||
Assert.Empty(policy.AllowedAtRules);
|
||||
Assert.False(policy.AllowDataAttributes);
|
||||
Assert.False(policy.AllowCssCustomProperties);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Every allowed element survives in a document that nests each one where HTML permits it.</summary>
|
||||
[Fact]
|
||||
public void Render_EveryAllowedElement_SurvivesSanitation()
|
||||
{
|
||||
const string Markup = """
|
||||
<div><p><a href="x.md">a</a><abbr>b</abbr><b>c</b><br><code>d</code><del>e</del><em>f</em><i>g</i><img src="i.png" alt="h"><input type="checkbox"><ins>j</ins><kbd>k</kbd><mark>l</mark><s>m</s><small>n</small><span>o</span><strong>p</strong><sub>q</sub><sup>r</sup></p><blockquote>s</blockquote><hr><pre>t</pre><h1>1</h1><h2>2</h2><h3>3</h3><h4>4</h4><h5>5</h5><h6>6</h6><dl><dt>u</dt><dd>v</dd></dl><details><summary>w</summary>x</details><ul><li>y</li></ul><ol><li>z</li></ol><table><caption>c</caption><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table></div>
|
||||
""";
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
|
||||
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), ElementNames(document), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>No other element survives between allowed elements; its text may remain but the element does not.</summary>
|
||||
[Fact]
|
||||
public void Render_EveryOtherElement_IsRemoved()
|
||||
{
|
||||
var renderer = new MarkdownRenderer();
|
||||
Assert.All(s_otherTags, tag =>
|
||||
{
|
||||
using IHtmlDocument document = Parse(renderer.Render($"<div><{tag} title=\"t\">inner</{tag}><span>after</span></div>"));
|
||||
Assert.Empty(ElementNames(document).Except(["div", "span"], StringComparer.Ordinal));
|
||||
});
|
||||
Assert.Empty(s_allowedTags.Intersect(s_otherTags, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Table parts that only exist inside a table, and are not allowed, are removed there while the allowed parts stay.</summary>
|
||||
[Fact]
|
||||
public void Render_TablePartsOutsideTheAllowlist_AreRemovedInsideATable()
|
||||
{
|
||||
const string Markup = "<table><colgroup><col span=\"2\"></colgroup><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>";
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
|
||||
Assert.Empty(document.Body!.QuerySelectorAll("colgroup,col,tfoot"));
|
||||
Assert.NotNull(document.Body.QuerySelector("table > thead > tr > th"));
|
||||
Assert.NotNull(document.Body.QuerySelector("table > tbody > tr > td"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Exactly the allowed attributes survive on an allowed element; identity, behaviour, styling and data attributes do not.</summary>
|
||||
[Fact]
|
||||
public void Render_EveryAttribute_KeepsExactlyTheAllowedOnes()
|
||||
{
|
||||
string attributes = string.Join(' ', s_allowedAttributes.Concat(s_otherAttributes).Select(name => string.Equals(name, "class", StringComparison.Ordinal) ? "class=\"frontmatter\"" : name + "=\"1\"")) + " style=\"color:red\"";
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<div><span {attributes}>text</span></div>"));
|
||||
IElement span = Assert.Single(document.Body!.QuerySelectorAll("span"));
|
||||
Assert.Equal(s_allowedAttributes, span.Attributes.Select(attribute => attribute.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
Assert.Empty(s_allowedAttributes.Intersect(s_otherAttributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the fixed styling classes and bounded code-language classes survive, spelled exactly.</summary>
|
||||
[Theory]
|
||||
[InlineData("frontmatter fm-key fm-value task-list task-list-item", "frontmatter fm-key fm-value task-list task-list-item")]
|
||||
[InlineData("language-csharp hljs keyword", "language-csharp")]
|
||||
[InlineData("LANGUAGE-csharp Language-go language-rust", "language-rust")]
|
||||
[InlineData("Frontmatter FM-KEY", null)]
|
||||
[InlineData("language- language-a;b language-<x>", null)]
|
||||
public void Render_ClassNames_KeepsOnlyTheStylingClasses(string authored, string? kept)
|
||||
{
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<span class=\"{authored}\">text</span>"));
|
||||
Assert.Equal(kept, Assert.Single(document.Body!.QuerySelectorAll("span")).GetAttribute("class"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A code-language class is kept up to 80 characters and dropped one character past that.</summary>
|
||||
[Fact]
|
||||
public void Render_CodeLanguageClassAtAndPastTheLengthLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = "language-" + new string('x', 71);
|
||||
Assert.Equal(80, atLimit.Length);
|
||||
using IHtmlDocument kept = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}\">text</span>"));
|
||||
Assert.Equal(atLimit, Assert.Single(kept.Body!.QuerySelectorAll("span")).GetAttribute("class"));
|
||||
using IHtmlDocument dropped = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}x\">text</span>"));
|
||||
Assert.Null(Assert.Single(dropped.Body!.QuerySelectorAll("span")).GetAttribute("class"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The attribute that carries source positions is allowed only during a source-aware render, whether that render finishes or fails.</summary>
|
||||
[Fact]
|
||||
public void RenderWithSources_AfterTheRender_NoLongerAllowsTheSourceAttribute()
|
||||
{
|
||||
var renderer = new MarkdownRenderer();
|
||||
var sources = new DocumentSourceMap("# Title");
|
||||
Assert.Contains("Title", renderer.RenderWithSources("# Title", FrontmatterMode.Hide, sources), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => renderer.RenderWithSources("# Title", (FrontmatterMode)99, sources));
|
||||
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only web and mail links keep a target; every other scheme, including ones Windows would hand to another application, is removed.</summary>
|
||||
[Theory]
|
||||
[InlineData("http://example.com/a", true)]
|
||||
[InlineData("https://example.com/a", true)]
|
||||
[InlineData("mailto:someone@example.com", true)]
|
||||
[InlineData("child.md", true)]
|
||||
[InlineData("#section", true)]
|
||||
[InlineData("HTTPS://example.com/a", true)]
|
||||
[InlineData("ftp://example.com/a", false)]
|
||||
[InlineData("file:///C:/private.txt", false)]
|
||||
[InlineData("FILE:///C:/private.txt", false)]
|
||||
[InlineData("javascript:run()", false)]
|
||||
[InlineData("vbscript:run()", false)]
|
||||
[InlineData("data:text/html,<p>x</p>", false)]
|
||||
[InlineData("blob:https://example.com/1", false)]
|
||||
[InlineData("about:blank", false)]
|
||||
[InlineData("ws://example.com", false)]
|
||||
[InlineData("tel:5551234", false)]
|
||||
[InlineData("sms:5551234", false)]
|
||||
[InlineData("ms-settings:defaultapps", false)]
|
||||
[InlineData("ms-msdt:/id", false)]
|
||||
[InlineData("ms-word:ofe|u|https://example.com/a.docx", false)]
|
||||
[InlineData("search-ms:query=x", false)]
|
||||
[InlineData("shell:startup", false)]
|
||||
[InlineData("res://ieframe.dll/x", false)]
|
||||
[InlineData("view-source:https://example.com", false)]
|
||||
[InlineData("onenote:x", false)]
|
||||
public void Render_LinkScheme_KeepsOnlyWebAndMailTargets(string target, bool kept)
|
||||
{
|
||||
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<a href=\"{target}\">link</a>"));
|
||||
IElement link = Assert.Single(document.Body!.QuerySelectorAll("a"));
|
||||
Assert.Equal(kept, link.HasAttribute("href"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The distinct element names in the sanitized body, in ordinal order.</summary>
|
||||
private static string[] ElementNames(IHtmlDocument document)
|
||||
=> document.Body!.QuerySelectorAll("*").Select(element => element.LocalName).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
|
||||
|
||||
|
||||
/// <summary>Parses renderer output the way the browser surface does.</summary>
|
||||
private static IHtmlDocument Parse(string html) => new HtmlParser().ParseDocument("<!doctype html><html><body>" + html + "</body></html>");
|
||||
}
|
||||
@@ -35,6 +35,10 @@ public sealed class SecurityFuzzTests
|
||||
"[section](#heading) \n", "| A | B |\n|---|---|\n| x | y |\n",
|
||||
"- [x] Task\n> Quote\n", "😀 café\0\t\r\n", "<!-- <code> -->", "</code></pre></div>",
|
||||
"</style><script>execute()</script>", "<script>literal</script>", "\"'<>:=[]()\\/!?%",
|
||||
"<video src='x.mp4' autoplay onplay='execute()'><source src='y.mp4'></video><audio src='x.mp3' controls></audio>",
|
||||
"<embed src='x.swf'><object data='x.swf'><param name='a' value='b'></object><math><mi xlink:href='javascript:execute()'>x</mi></math>",
|
||||
"<link rel='stylesheet' href='https://outside.invalid/x.css'><meta http-equiv='refresh' content='0;url=https://outside.invalid'><base href='https://outside.invalid/'>",
|
||||
"<textarea autofocus onfocus='execute()'>text</textarea><select><option>one</option></select><a href='x.md' target='_blank' name='n' ping='https://outside.invalid'>x</a>",
|
||||
];
|
||||
|
||||
|
||||
@@ -242,11 +246,12 @@ public sealed class SecurityFuzzTests
|
||||
/// <summary>Uses the parsed DOM's executable capabilities as the oracle, not the renderer's private allowlist.</summary>
|
||||
private static void AssertPassive(IHtmlDocument document)
|
||||
{
|
||||
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta[http-equiv],audio,video"));
|
||||
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta,link,audio,video,source,param,textarea,select,option"));
|
||||
foreach(IElement element in document.QuerySelectorAll("*"))
|
||||
{
|
||||
Assert.DoesNotContain(element.Attributes, attribute => attribute.Name.StartsWith("on", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(element.HasAttribute("srcdoc") || element.HasAttribute("srcset") || element.HasAttribute("style"));
|
||||
Assert.False(element.HasAttribute("target") || element.HasAttribute("ping") || element.HasAttribute("name") || element.HasAttribute("autofocus"));
|
||||
foreach(string name in new[] { "href", "src" })
|
||||
{
|
||||
string? value = element.GetAttribute(name);
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
using MarkdownViewr.Windows.Core.Editors;
|
||||
using MarkdownViewr.Windows.Core.Printing;
|
||||
using MarkdownViewr.Windows.Core.Themes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation of text that comes from a document, a settings file, an imported theme or the
|
||||
/// browser surface: each check is pinned by an input that only that check rejects.
|
||||
/// </summary>
|
||||
public sealed class UntrustedInputValidationTests
|
||||
{
|
||||
/// <summary>Metadata keys and values are display text: markup in either is escaped, never parsed.</summary>
|
||||
[Fact]
|
||||
public void RenderMetadata_MarkupInKeyAndValue_IsEscapedText()
|
||||
{
|
||||
const string Key = "<b>key</b>";
|
||||
const string Value = "<img src=x onerror=run()> & \"quoted\"";
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(Key + ": " + Value));
|
||||
Assert.Equal(Key, Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
|
||||
Assert.Equal(Value, Assert.Single(parsed.QuerySelectorAll("td.fm-value")).TextContent);
|
||||
Assert.Empty(parsed.QuerySelectorAll("b,img"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A line that starts with its colon still has a key cell, so the table keeps two columns.</summary>
|
||||
[Fact]
|
||||
public void RenderMetadata_LeadingColon_KeepsAnEmptyKeyCell()
|
||||
{
|
||||
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(": value"));
|
||||
Assert.Equal("", Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
|
||||
IElement value = Assert.Single(parsed.QuerySelectorAll("td.fm-value"));
|
||||
Assert.Equal("value", value.TextContent);
|
||||
Assert.False(value.HasAttribute("colspan"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The private source marker is new for every render, so an authored attribute cannot predict it.</summary>
|
||||
[Fact]
|
||||
public void AttributeName_EachSourceMap_IsUnpredictable()
|
||||
{
|
||||
string first = new DocumentSourceMap("# Title").AttributeName;
|
||||
string second = new DocumentSourceMap("# Title").AttributeName;
|
||||
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", first);
|
||||
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", second);
|
||||
Assert.NotEqual(first, second, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Raw HTML headings map to their source at every level, including the sixth.</summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
public void SourceHeadings_RawHtmlHeadingLevel_MapsItsSource(int level)
|
||||
{
|
||||
string heading = $"<h{level}>Raw</h{level}>";
|
||||
string source = "Intro\n\n" + heading + "\n\nAfter\n";
|
||||
var outline = new DocumentOutline(source);
|
||||
Assert.Equal(level, Assert.Single(outline.Headings).Level);
|
||||
DocumentSourceHeading mapped = Assert.Single(outline.SourceHeadings);
|
||||
Assert.Equal(heading, source[mapped.Start..mapped.End]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A level the HTML standard does not define is not a heading and gets no source mapping.</summary>
|
||||
[Fact]
|
||||
public void SourceHeadings_RawHtmlLevelSeven_IsNotAHeading()
|
||||
{
|
||||
var outline = new DocumentOutline("<h7>Not a heading</h7>\n");
|
||||
Assert.Empty(outline.Headings);
|
||||
Assert.Empty(outline.SourceHeadings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Loading repairs every stored list and object, not only the ones the window happens to read first.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_CorruptPrintEditorsAndThemeLists_RepairsEachProperty()
|
||||
{
|
||||
var valid = new EditorConfiguration(Guid.NewGuid(), "Editor", @"C:\Editor\editor.exe");
|
||||
var settings = new AppSettings
|
||||
{
|
||||
Print = new PrintOptions(Theme: "Bad\u0001Theme", PaddingPixels: 5000),
|
||||
ConfiguredEditors = [valid, new EditorConfiguration(Guid.NewGuid(), "Script", @"C:\Editor\run.cmd"), new EditorConfiguration(Guid.Empty, "Empty", @"C:\Editor\other.exe")],
|
||||
DisabledThemes = ["Nord", "Nord", "Bad\u0001Name", ""],
|
||||
DeletedBuiltInThemes = ["Dracula", "Dracula", "Bad\u0002Name"],
|
||||
ThemeOrder = ["Nord", "Nord", "Bad\u0003Name"],
|
||||
DocumentTheme = "Bad\u0001Theme",
|
||||
};
|
||||
settings.Sanitize();
|
||||
Assert.Equal("Clean Printing", settings.Print.Theme);
|
||||
Assert.Equal(96, settings.Print.PaddingPixels);
|
||||
Assert.Equal([valid], settings.ConfiguredEditors);
|
||||
Assert.Equal(valid.Executable, settings.ExternalEditor);
|
||||
Assert.Equal(["Nord"], settings.DisabledThemes);
|
||||
Assert.Equal(["Dracula"], settings.DeletedBuiltInThemes);
|
||||
Assert.Equal(["Nord"], settings.ThemeOrder);
|
||||
Assert.Equal("Catppuccin Mocha", settings.DocumentTheme);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A missing print object, as an older file or a hand edit leaves it, becomes the defaults.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_MissingPrintObject_UsesDefaults()
|
||||
{
|
||||
var settings = new AppSettings { Print = null! };
|
||||
settings.Sanitize();
|
||||
Assert.Equal(new PrintOptions(), settings.Print);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Editing a theme cannot silently raise it to a newer schema; that needs the import confirmation.</summary>
|
||||
[Fact]
|
||||
public void SaveEdited_SchemaVersionRaised_RequiresConfirmation()
|
||||
{
|
||||
ReaderTheme original = ThemeCatalog.Find(null);
|
||||
JsonNode raised = JsonNode.Parse(original.Json)!;
|
||||
raised["schemaVersion"] = 2;
|
||||
var edited = ReaderTheme.ParseCompatible(raised.ToJsonString());
|
||||
Assert.Equal(1, original.SchemaVersion);
|
||||
Assert.Equal(2, edited.SchemaVersion);
|
||||
Assert.Throws<InvalidOperationException>(() => ThemeManagement.SaveEdited(new AppSettings(), original, edited));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An already confirmed newer-schema theme stays editable at its own version.</summary>
|
||||
[Fact]
|
||||
public void SaveEdited_SameNewerSchema_IsSaved()
|
||||
{
|
||||
JsonNode newer = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
|
||||
newer["schemaVersion"] = 2;
|
||||
newer["name"] = "Owned future";
|
||||
var theme = ReaderTheme.ParseCompatible(newer.ToJsonString());
|
||||
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme, approveNewerSchema: true);
|
||||
AppSettings saved = ThemeManagement.SaveEdited(imported, theme, theme);
|
||||
Assert.Equal("Owned future", saved.DocumentTheme);
|
||||
Assert.Equal(2, ThemeManagement.Active(saved).SchemaVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A copy of a theme whose name is already at the limit still has a name the theme parser accepts.</summary>
|
||||
[Fact]
|
||||
public void CopyTheme_LongestNameTakenTwice_StaysWithinTheNameLimit()
|
||||
{
|
||||
ReaderTheme longest = ThemeCatalog.Find(null).Rename(new string('N', 80));
|
||||
AppSettings settings = ThemeManagement.Import(new AppSettings(), longest);
|
||||
ReaderTheme firstCopy = ThemeManagement.CopyTheme(settings, longest);
|
||||
settings = ThemeManagement.Import(settings, firstCopy);
|
||||
ReaderTheme secondCopy = ThemeManagement.CopyTheme(settings, longest);
|
||||
Assert.Equal(80, firstCopy.Name.Length);
|
||||
Assert.Equal(80, secondCopy.Name.Length);
|
||||
Assert.EndsWith(" (2)", secondCopy.Name, StringComparison.Ordinal);
|
||||
Assert.Equal(secondCopy.Name, ReaderTheme.ParseCompatible(secondCopy.Json).Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Editor and document paths are accepted up to the Windows limit and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void IsExecutablePath_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = @"C:\" + new string('e', 32767 - 7) + ".exe";
|
||||
string pastLimit = @"C:\" + new string('e', 32768 - 7) + ".exe";
|
||||
Assert.Equal(32767, atLimit.Length);
|
||||
Assert.True(EditorLaunch.IsExecutablePath(atLimit));
|
||||
Assert.False(EditorLaunch.IsExecutablePath(pastLimit));
|
||||
string document = @"C:\" + new string('d', 32767 - 6) + ".md";
|
||||
Assert.Equal(32767, document.Length);
|
||||
Assert.Equal(document, Assert.Single(EditorLaunch.Create(@"C:\Editor\editor.exe", document).ArgumentList));
|
||||
Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", document + "x"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Recent paths are kept up to the Windows limit and dropped one character past it.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_RecentPathAtAndPastTheWindowsLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = @"C:\" + new string('d', 32767 - 6) + ".md";
|
||||
Assert.Equal([atLimit], RecentDocuments.Sanitize([atLimit, atLimit + "x"]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A find term is accepted at its limit and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void Constructor_FindTermAtAndPastTheLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = new('q', DocumentFind.MaximumQueryCharacters);
|
||||
Assert.Equal(atLimit, new DocumentFind(atLimit + " tail", atLimit).Query);
|
||||
Assert.Throws<ArgumentException>(() => new DocumentFind("source", atLimit + "q"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The viewport result is read up to its size cap and ignored past it, even when it would parse.</summary>
|
||||
[Fact]
|
||||
public void FromBrowserResult_ViewportAtAndPastTheSizeCap_IsBoundedExactly()
|
||||
{
|
||||
string atCap = "[0.25," + new string(' ', 128 - 11) + "0.75]";
|
||||
Assert.Equal(128, atCap.Length);
|
||||
Assert.Equal(0.75, DocumentViewport.FromBrowserResult(atCap).VerticalFraction);
|
||||
Assert.Equal(default, DocumentViewport.FromBrowserResult(atCap.Replace("[", "[ ", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The scroll-state result is read up to its size cap for the heading count and ignored past it.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
public void FromBrowserResult_ScrollStateAtAndPastTheSizeCap_IsBoundedExactly(int headings)
|
||||
{
|
||||
int cap = headings * 128 + 512;
|
||||
string atCap = "[1,2,[],[]" + new string(' ', cap - 11) + "]";
|
||||
Assert.Equal(cap, atCap.Length);
|
||||
Assert.NotNull(DocumentScrollState.FromBrowserResult(atCap, headings));
|
||||
Assert.Null(DocumentScrollState.FromBrowserResult(atCap.Replace("[1,", "[ 1,", StringComparison.Ordinal), headings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The scroll-state result has exactly four parts; a longer array is not the application's own query result.</summary>
|
||||
[Theory]
|
||||
[InlineData("[1,2,[]]")]
|
||||
[InlineData("[1,2,[],[],[]]")]
|
||||
[InlineData("[1,2,[],[],0]")]
|
||||
public void FromBrowserResult_ScrollStateWithWrongNumberOfParts_IsRejected(string json)
|
||||
=> Assert.Null(DocumentScrollState.FromBrowserResult(json, 2));
|
||||
|
||||
|
||||
|
||||
/// <summary>Print styles are built from repaired options, so a corrupt padding value cannot reach the stylesheet.</summary>
|
||||
[Theory]
|
||||
[InlineData(double.NaN, "padding:0px")]
|
||||
[InlineData(double.PositiveInfinity, "padding:0px")]
|
||||
[InlineData(5000, "padding:96px")]
|
||||
[InlineData(-3, "padding:0px")]
|
||||
public void Build_CorruptPadding_UsesTheRepairedValue(double padding, string expected)
|
||||
{
|
||||
ReaderTheme theme = ThemeCatalog.Find(null);
|
||||
string css = PrintStyles.Build(new PrintOptions(PaddingPixels: padding), theme, [theme], 1);
|
||||
Assert.Contains(expected, css, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("NaN", css, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Infinity", css, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("∞", css, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Text the JSON reader cannot decode, an unpaired surrogate escape, makes the theme invalid
|
||||
/// wherever it appears: in a value the theme reads, in a property name, or in a value this
|
||||
/// version ignores but would later re-serialize. So does a repeated property name.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"\\uD83D\"")]
|
||||
[InlineData("\"background\": \"#2e3440\"", "\"background\": \"\\uD800\"")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"A\\uDC00B\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"\\uD83D\": 1")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"\\uD83D\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [1, [\"ok\", {\"deep\": \"\\uDFFF\"}]]")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"\\uD800x\": \"y\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"name\": \"Nord\"")]
|
||||
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"body\": \"Other\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"extra\": {\"a\": 1, \"a\": 2}")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"na\\u006de\": \"Nord\"")]
|
||||
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [\"\\uD83D\"]")]
|
||||
public void Parse_UndecodableTextOrRepeatedProperty_IsRejectedAsAnInvalidTheme(string valid, string invalid)
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Contains(valid, original, StringComparison.Ordinal);
|
||||
string json = original.Replace(valid, invalid, StringComparison.Ordinal);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
|
||||
var settings = new AppSettings { UserThemes = [json, original] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([original], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A theme that parses can be renamed, copied, edited and imported under a new name with its
|
||||
/// extra properties intact: those operations re-serialize the whole document. Names that differ
|
||||
/// only in case, and the same name in two different objects, are not repeats.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_AcceptedThemeWithExtraProperties_SurvivesRenameCopyEditAndImport()
|
||||
{
|
||||
string json = ThemeCatalog.Find("Nord").Json.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"Name\": \"other case\", \"note\": \"caf\\u00e9 \\ud83d\\ude00\", \"future\": {\"name\": \"inner\", \"list\": [1, \"two\", null]}", StringComparison.Ordinal);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
Assert.Equal("Renamed", theme.Rename("Renamed").Name);
|
||||
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme);
|
||||
Assert.Contains(ThemeManagement.All(imported), item => item.Name.StartsWith("Nord (", StringComparison.Ordinal));
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(imported, theme).Name, StringComparison.Ordinal);
|
||||
ReaderTheme edited = theme.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
|
||||
Assert.Equal("Edited", edited.Name);
|
||||
Assert.Contains("caf", edited.Json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"Name\":\"other case\"", edited.Json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"name\":\"inner\"", edited.Json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renaming, copying and editing write characters as themselves, so a theme near the size
|
||||
/// limit whose text is full of quote marks, angle brackets or non-ASCII characters stays
|
||||
/// within the limit instead of growing sixfold and being refused.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a>b+c{font:'x'}")]
|
||||
[InlineData("/* \u6f22\u5b57 caf\u00e9 & <tag> */")]
|
||||
public void Rename_ThemeNearTheSizeLimit_DoesNotGrowPastIt(string fragment)
|
||||
{
|
||||
string css = string.Concat(Enumerable.Repeat(fragment, 60_000 / fragment.Length));
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
Assert.Contains("\"name\": \"Nord\"", original, StringComparison.Ordinal);
|
||||
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"" + css + "\"", StringComparison.Ordinal);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
ReaderTheme renamed = theme.Rename("Renamed");
|
||||
Assert.True(renamed.Json.Length <= json.Length);
|
||||
Assert.Contains(fragment, renamed.Json, StringComparison.Ordinal);
|
||||
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
|
||||
Assert.True(edited.Json.Length <= json.Length);
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), theme).Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// JSON writes emoji, no-break and ideographic spaces and private-use glyphs as six-character
|
||||
/// escapes whatever the encoder. A theme as large as an import may be, made of nothing else,
|
||||
/// can still be renamed, copied and edited: the stored limit leaves room for the growth.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("😀")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("")]
|
||||
public void Rename_ThemeAtTheImportLimitMadeOfEscapedCharacters_IsKept(string character)
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
string opening = "\"name\": \"Nord\", \"note\": \"";
|
||||
int room = ReaderTheme.MaximumCharacters - original.Length - (opening.Length - "\"name\": \"Nord\"".Length) - 1;
|
||||
string filler = string.Concat(Enumerable.Repeat(character, room / character.Length)).PadRight(room, 'x');
|
||||
string json = original.Replace("\"name\": \"Nord\"", opening + filler + "\"", StringComparison.Ordinal);
|
||||
Assert.Equal(ReaderTheme.MaximumCharacters, json.Length);
|
||||
var theme = ReaderTheme.Parse(json);
|
||||
ReaderTheme renamed = theme.Rename("A considerably longer name than the one it had");
|
||||
Assert.InRange(renamed.Json.Length, json.Length + 1, ReaderTheme.MaximumStoredCharacters);
|
||||
Assert.Equal(renamed.Json.Length, renamed.Rename(renamed.Name).Json.Length);
|
||||
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), renamed).Name, StringComparison.Ordinal);
|
||||
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "body { color: red }");
|
||||
Assert.Equal("Edited", edited.Name);
|
||||
var settings = new AppSettings { UserThemes = [edited.Json] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([edited.Json], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A string that is not well-formed UTF-16 is refused as an invalid theme, and a stored one is dropped, rather than failing as a programming error.</summary>
|
||||
[Fact]
|
||||
public void Parse_TextWithAnUnpairedSurrogate_IsRejectedAsAnInvalidTheme()
|
||||
{
|
||||
string original = ThemeCatalog.Find("Nord").Json;
|
||||
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"No\uD83Drd\"", StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"name\": \"Nord\"", json, StringComparison.Ordinal);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
|
||||
var settings = new AppSettings { UserThemes = [json, original] };
|
||||
settings.Sanitize();
|
||||
Assert.Equal([original], settings.UserThemes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the exact lower-case channel name selects the development identity.</summary>
|
||||
[Theory]
|
||||
[InlineData("DEV")]
|
||||
[InlineData("Dev")]
|
||||
[InlineData("dev ")]
|
||||
[InlineData("development")]
|
||||
public void ForChannel_OtherSpellingOfDev_KeepsTheStableIdentity(string channel)
|
||||
=> Assert.Equal("markdownviewr-windows", AppIdentity.ForChannel(channel).Id);
|
||||
|
||||
|
||||
|
||||
/// <summary>A colour that is not six hexadecimal digits after a hash, or a label containing a control character, is refused.</summary>
|
||||
[Theory]
|
||||
[InlineData("colors.background", "\";ffffff\"")]
|
||||
[InlineData("colors.background", "\"#fffffff\"")]
|
||||
[InlineData("colors.background", "\"#ffffffff\"")]
|
||||
[InlineData("colors.background", "\"#fffff\"")]
|
||||
[InlineData("colors.background", "\"ffffff#\"")]
|
||||
[InlineData("colors.text", "\"#12345g\"")]
|
||||
[InlineData("name", "\"Theme\\u0001Name\"")]
|
||||
[InlineData("name", "\"Theme\\u007fName\"")]
|
||||
[InlineData("fonts.heading", "\"Font\\u0001Name\"")]
|
||||
[InlineData("fonts.code", "\"Font\\u009bName\"")]
|
||||
public void Parse_MalformedColorOrLabel_IsRejected(string path, string json)
|
||||
{
|
||||
JsonNode root = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
|
||||
string[] parts = path.Split('.');
|
||||
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
|
||||
parent[parts[^1]] = JsonNode.Parse(json);
|
||||
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
using MarkdownViewr.Windows.Core.Themes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>The written-out Windows name rules, and the two places that depend on them.</summary>
|
||||
public sealed class WindowsFileNamesTests
|
||||
{
|
||||
private static readonly string[] s_devices =
|
||||
[
|
||||
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
|
||||
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
|
||||
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
|
||||
];
|
||||
|
||||
|
||||
|
||||
/// <summary>A device is a device in any letter case, with any extension, and with spaces before the extension or at the end.</summary>
|
||||
[Fact]
|
||||
public void IsReservedDeviceName_EveryDeviceSpelling_IsReserved()
|
||||
{
|
||||
Assert.All(Spellings(), spelling =>
|
||||
{
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".png"), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".tar.md"), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " .png"), spelling);
|
||||
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " "), spelling);
|
||||
});
|
||||
Assert.Equal(96, Spellings().Count());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Names that only resemble a device stay ordinary file names; only spaces are ignored before the extension.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("console.md")]
|
||||
[InlineData("COM10.png")]
|
||||
[InlineData("COM")]
|
||||
[InlineData("LPT.txt")]
|
||||
[InlineData("xCON.png")]
|
||||
[InlineData("CON-notes.md")]
|
||||
[InlineData("CON-.md")]
|
||||
[InlineData("null.md")]
|
||||
[InlineData(" NUL.png")]
|
||||
[InlineData("NUL\u00A0.png")]
|
||||
[InlineData("NUL\t.png")]
|
||||
[InlineData("readme.NUL")]
|
||||
[InlineData("COM⁴.png")]
|
||||
[InlineData("CLOCK$")]
|
||||
[InlineData("CLOCK$.txt")]
|
||||
public void IsReservedDeviceName_OrdinaryName_IsNotReserved(string name) => Assert.False(WindowsFileNames.IsReservedDeviceName(name));
|
||||
|
||||
|
||||
|
||||
/// <summary>A missing name is a contract error, not an ordinary file.</summary>
|
||||
[Fact]
|
||||
public void IsReservedDeviceName_Null_RejectsInput() => Assert.Throws<ArgumentNullException>(() => WindowsFileNames.IsReservedDeviceName(null!));
|
||||
|
||||
|
||||
|
||||
/// <summary>The printable characters Windows forbids in a name; control characters are refused separately by each caller.</summary>
|
||||
[Fact]
|
||||
public void InvalidCharacters_Constant_ListsThePrintableForbiddenCharacters() => Assert.Equal("\"*/:<>?\\|", new string(WindowsFileNames.InvalidCharacters.Order().ToArray()));
|
||||
|
||||
|
||||
|
||||
/// <summary>No image or linked-document request can name a device in any spelling, as the file or as a folder on the way to it.</summary>
|
||||
[Fact]
|
||||
public void GetRelativePath_DeviceSegment_IsDenied()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.All(Spellings(), spelling =>
|
||||
{
|
||||
string segment = Uri.EscapeDataString(spelling);
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + ".png")));
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "%20.png")));
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "/picture.png")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".md")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".tar.md")));
|
||||
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/" + segment + "/child.md")));
|
||||
});
|
||||
Assert.Equal("console.png", policy.GetImageRelativePath(new Uri(policy.DocumentUri, "console.png")));
|
||||
Assert.Equal("docs/com10/child.md", policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/com10/child.md")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A theme named after a device, in any case, never becomes the suggested export file name.</summary>
|
||||
[Fact]
|
||||
public void SuggestedFileName_EveryDeviceName_IsPrefixed()
|
||||
{
|
||||
Assert.All(s_devices, device =>
|
||||
{
|
||||
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device)));
|
||||
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device.ToLowerInvariant())));
|
||||
Assert.Equal("theme-" + device.ToLowerInvariant() + ".backup.json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device + ".backup")));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Leading and trailing dots and hyphens are dropped from a suggested name, and a name made only of them falls back to a plain one.</summary>
|
||||
[Theory]
|
||||
[InlineData("-Edge-", "edge.json")]
|
||||
[InlineData(".Dotted.", "dotted.json")]
|
||||
[InlineData("--", "theme.json")]
|
||||
[InlineData(" spaced ", "spaced.json")]
|
||||
[InlineData("-con-", "theme-con.json")]
|
||||
public void SuggestedFileName_LeadingAndTrailingSeparators_AreTrimmed(string name, string expected)
|
||||
=> Assert.Equal(expected, ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(name)));
|
||||
|
||||
|
||||
|
||||
/// <summary>Each device name in upper, lower and mixed case.</summary>
|
||||
private static IEnumerable<string> Spellings()
|
||||
=> s_devices.SelectMany(device => new[] { device, device.ToLowerInvariant(), char.ToUpperInvariant(device[0]) + device[1..].ToLowerInvariant() });
|
||||
}
|
||||
Reference in new issue
Block a user