Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.2 af81ba050a chore: merge main into fix/settings-store
node / node (push) Successful in 14s
build / scripts (push) Successful in 13s
windows / windows (push) Successful in 1m52s
build / windows (push) Successful in 1m52s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 19:08:03 -06:00
james.maesandClaude Fable 5.2 181950b73a fix(settings): save when the invalid file cannot be copied
A copy of an invalid settings file that cannot be written, for example
on a full disk, is now reported and the save goes ahead, as it does
when the copy cannot be given its name. Before, the save was refused.

On a volume that needs the fallback move, a read-only settings file is
recognized by its attribute and refused at once instead of after the
full wait.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 19:07:46 -06:00
james.maesandClaude Fable 5.2 34b6aba0ff fix(settings): save to long paths and place kept copies after the save
node / node (push) Successful in 31s
build / scripts (push) Successful in 31s
windows / windows (push) Successful in 2m24s
build / windows (push) Successful in 2m24s
The rename that replaces the settings file now takes the path in the
form Windows reads as written, so a data folder past 260 characters,
or one whose name is not well-formed UTF-16, saves like any other.
Before, every save after the first was refused there.

The copy of an invalid file is written to a temporary name and given
its kept-aside name only after the save succeeds, so a failed save can
no longer overwrite and then delete an earlier copy, and a killed save
leaves no duplicate copies. A file over 16 MiB is replaced without a
copy.

On a volume that needs the fallback move, a reader's open handle is
waited out instead of failing the save at once.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 18:43:34 -06:00
james.maesandClaude Fable 5.2 80e00b3ed1 fix(settings): replace the settings file in one rename
node / node (push) Successful in 27s
build / scripts (push) Successful in 27s
windows / windows (push) Successful in 2m48s
build / windows (push) Successful in 2m48s
The stored file is now replaced with a single rename that works while
other handles are open on it, so there is never a moment without a
settings file and a process killed mid-save leaves the old file or the
new one. Volumes without that rename fall back to a move.

Content the JSON reader cannot decode is treated as invalid content
rather than an error, the read handle is released on every path, and a
kept-aside copy that cannot be made no longer blocks the save. Clearing
recent files also deletes kept-aside copies.

ADR-0006, the user guide and the threat model describe the protocol as
implemented.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 17:59:39 -06:00
james.maesandClaude Fable 5.2 600825030a fix(settings): never set aside a file another program is writing
node / node (push) Successful in 23s
build / scripts (push) Successful in 23s
windows / windows (push) Successful in 1m53s
build / windows (push) Successful in 1m52s
Reading while denying writers makes a file that is being rewritten in
place read as busy instead of as invalid content, and the read handle is
held until the swap, so the content judged is the content replaced. The
swap uses a replace operation: an invalid original moves to its kept-aside
name in the same step, a failed save leaves it in place, and a reader that
allows replacement no longer blocks a save.

Access denied is permanent and reported at once; only a sharing violation
is waited out, so a read-only profile no longer stalls a window. One
invalid value costs only that value, a trailing comma, a comment and a
quoted number are accepted, and a saved theme the JSON reader cannot
decode is discarded instead of stopping startup. Kept-aside files use the
first free of nine names. Only this store's own temporary files are
removed. ADR-0006 records the on-disk protocol.

Tests cover each of these with real handles held the way other programs
hold them, a deterministic interleaving of two instances, the on-disk
format against the previous store, and bounded retries in the concurrent
test.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:57:37 -06:00
james.maesandClaude Fable 5.2 6919d3ce4a fix(settings): keep stored preferences when the file is shared or unreadable
node / node (push) Successful in 14s
build / scripts (push) Successful in 14s
windows / windows (push) Successful in 1m58s
build / windows (push) Successful in 1m58s
A load that hit a sharing violation returned defaults, and the next save
wrote them back over the stored themes, CSS, editors and recent files. Two
processes over one data folder also erased each other's changes: each did
load, modify, save with no lock and one shared temporary file name.

AppSettingsStore applies one change to the latest stored settings under a
lock file beside settings.json and replaces the file by rename. A file that
cannot be read after bounded retries is never overwritten; only invalid
content counts as corruption and is kept as settings.json.corrupt. A file
over 16 MiB is not loaded, abandoned temporary files are removed, and
faults are logged by kind, exception type and HRESULT only.

Every save in the app now goes through Update. Tests use real exclusive
handles and concurrent instances and count attempts instead of time.

Closes #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:09:55 -06:00
23 changed files with 2480 additions and 48 deletions

No files matched your search

@@ -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
+138
View File
@@ -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.
+1 -1
View File
@@ -34,7 +34,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Remote resources | Denied by CSP and resource interception |
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths |
| Settings / 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 |
+2
View File
@@ -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).
@@ -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.
}
}
}
+106
View File
@@ -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,
}
+16 -7
View File
@@ -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;
}
+8 -3
View File
@@ -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;
+1 -2
View File
@@ -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
@@ -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));
}