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>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-10 17:59:39 -06:00
1 parent 600825030a
commit 80e00b3ed1
11 files changed
+820 -112

No files matched your search

+51 -23
View File
@@ -32,42 +32,64 @@ update. It is never deleted: deleting a lock file leaves a window in which a sec
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.** The file is opened for reading while denying writers. A program that is
rewriting the file in place therefore reads as busy, not as invalid content, and
during an update no such program can start until the swap has happened. Readers
allow the file to be replaced, so a read never blocks a save.
**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 values | Valid values kept, invalid ones as defaults | Replaced; the original is kept aside |
| Not settings at all | Defaults | Replaced; the original is kept aside |
| 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 |
| 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. A file over 16 MiB is not loaded.
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`,
flushed to disk, and swapped in with a replace operation, which also moves an invalid
original to its kept-aside name in the same step. If the file did not exist when it
was read, the temporary file is renamed into place without overwriting. A failed
**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.
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 files.** `settings.json.corrupt`, then `settings.json.corrupt-2` to
`settings.json.corrupt-9`; the first free name is used, and the last is reused once
all exist. Nothing reads them back; they exist so a hand edit gone wrong can be
recovered.
**Kept-aside copies.** Before invalid content is replaced it is copied 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. The copy is a
courtesy for recovering a hand edit: if it cannot be made the fault is reported and
the save proceeds, because refusing would block every later change too. A save that
then fails removes the copy it made. 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 swap
**Abandoned temporary files.** A process killed between its write and its rename
leaves a temporary file. 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 swap. Access denied is treated as permanent and
reported at once, so a read-only profile does not stall a window.
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.
**Reporting.** Faults are reported by kind with the exception type and HRESULT. No
path and no operating-system message text reaches the log.
@@ -77,11 +99,14 @@ path and no operating-system message text reaches the log.
- 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.
- An earlier release running against the same folder neither takes the lock nor
denies writers. While it is mid-save, this store reads the file as busy and refuses
the update; while this store holds the file, the earlier release's save fails and
it reports that the setting could not be saved. Neither loses stored data, but the
two can refuse each other until both are the same version.
- 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).
@@ -90,6 +115,9 @@ path and no operating-system message text reaches the log.
- **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 changes are read-modify-write under a lock file beside `settings.json` and are swapped in atomically; a file that is unreadable or being written by another program is never overwritten, invalid content is kept aside under a bounded set of names, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. 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 |
+1 -1
View File
@@ -74,7 +74,7 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
- **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. A value that is not valid is ignored and the rest are kept. The next save keeps the file as it was beside the new one, as `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`).
- **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 setting whose value is not valid is ignored and the other settings are kept. When that happens, the next save first copies the file as it was to `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).
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -12,41 +13,50 @@ 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 swapped in atomically,
/// so two instances cannot erase each other's change. 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 the original is kept aside.
/// See ADR-0006 for the on-disk protocol.
/// 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 swap before the operation gives up.</summary>
/// <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>Invalid files kept aside before the newest slot is reused.</summary>
/// <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 const int LockViolation = unchecked((int)0x80070021);
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;
}
@@ -56,11 +66,14 @@ public sealed class AppSettingsStore
/// <summary>Reads the stored settings for display. A missing, invalid, or unreadable file yields defaults and is left untouched.</summary>
/// <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(out AppSettings settings, out FileStream? stored);
stored?.Dispose();
_ = TryRead(forUpdate: false, out AppSettings settings, out _);
return settings;
}
@@ -75,6 +88,7 @@ public sealed class AppSettingsStore
public AppSettings? Update(Func<AppSettings, AppSettings> change)
{
ArgumentNullException.ThrowIfNull(change);
FileStream? stored = null;
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
@@ -83,26 +97,42 @@ public sealed class AppSettingsStore
{
return null;
}
SettingsRead read = TryRead(out AppSettings current, out FileStream? stored);
// The read handle denies writers until the swap, so the content judged here is the content replaced.
using(stored)
// 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)
{
if(read == SettingsRead.Unreadable)
{
return null;
}
AppSettings edited = change(current);
edited.Sanitize();
RemoveAbandonedTemporaryFiles();
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored is not null, read is SettingsRead.Corrupt or SettingsRead.Repaired);
return edited;
return null;
}
AppSettings edited = change(current);
edited.Sanitize();
RemoveAbandonedTemporaryFiles();
string? aside = read is SettingsRead.Corrupt or SettingsRead.Repaired ? KeepAside(stored!) : null;
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored, aside);
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));
}
}
@@ -129,43 +159,122 @@ public sealed class AppSettingsStore
/// <summary>
/// Opens the file denying writers, so a program caught rewriting it in place reads as busy
/// rather than as invalid content, then distinguishes missing, valid, repaired and invalid.
/// The open handle is returned so the caller can keep writers out until it has finished.
/// 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(out AppSettings settings, out FileStream? stored)
private SettingsRead TryRead(bool forUpdate, out AppSettings settings, out FileStream? stored)
{
settings = new AppSettings();
stored = null;
SettingsRead result;
try
SettingsRead? read = null;
for(int attempt = 1; read is null; attempt++)
{
FileStream? stream = null;
Retry(() => stream = new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete));
stored = stream;
result = Parse(stream!, ref settings);
if(attempt > 1)
{
_wait(RetryDelay);
}
read = Attempt(forUpdate, attempt == MaximumAttempts, ref settings, ref stored);
}
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
if(read is SettingsRead.Corrupt or SettingsRead.Repaired)
{
result = SettingsRead.Missing;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
_report(SettingsFault.Unreadable, exception);
result = SettingsRead.Unreadable;
_report(read == SettingsRead.Corrupt ? SettingsFault.Corrupt : SettingsFault.Repaired, null);
}
settings.Sanitize();
return result;
return read.Value;
}
/// <summary>Reads at most <see cref="MaximumBytes"/>; one invalid value costs only that value, never the whole profile.</summary>
private SettingsRead Parse(FileStream stream, ref AppSettings settings)
/// <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)
{
_report(SettingsFault.Corrupt, null);
return SettingsRead.Corrupt;
}
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
@@ -178,22 +287,20 @@ public sealed class AppSettingsStore
return SettingsRead.Loaded;
}
}
catch(JsonException)
catch(Exception exception) when(IsInvalidContent(exception))
{
if(Salvage(json) is { } repaired)
{
settings = repaired;
_report(SettingsFault.Repaired, null);
return SettingsRead.Repaired;
}
}
_report(SettingsFault.Corrupt, null);
return SettingsRead.Corrupt;
}
/// <summary>Keeps every stored value 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>
/// <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
@@ -208,14 +315,14 @@ public sealed class AppSettingsStore
{
_ = new JsonObject { [name] = stored[name]?.DeepClone() }.Deserialize(AppSettingsContext.Default.AppSettings);
}
catch(JsonException)
catch(Exception exception) when(IsInvalidContent(exception))
{
stored.Remove(name);
}
}
return stored.Deserialize(AppSettingsContext.Default.AppSettings);
}
catch(Exception exception) when(exception is JsonException or ArgumentException)
catch(Exception exception) when(IsInvalidContent(exception) || exception is ArgumentException)
{
// Not JSON, or an object that repeats a property name.
return null;
@@ -225,10 +332,54 @@ public sealed class AppSettingsStore
/// <summary>
/// Writes beside the file and flushes to disk, then swaps it in. A failure leaves the stored
/// file and no temporary file. Invalid content being replaced is kept under a free name.
/// 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 void Replace(byte[] content, bool exists, bool keepAside)
private static bool IsInvalidContent(Exception exception) => exception is JsonException or InvalidOperationException;
/// <summary>
/// Copies an invalid file to the first free kept-aside name before it is replaced, or over the
/// last name once all are taken, and returns the copy's path. The copy is a courtesy: failing
/// to make it is reported and does not stop the save, since refusing would block every later
/// change as well; null is returned then.
/// </summary>
private string? KeepAside(FileStream stored)
{
int slot = 1;
while(slot < MaximumKeptAside && (File.Exists(KeptAsideName(slot)) || Directory.Exists(KeptAsideName(slot))))
{
slot++;
}
try
{
using var copy = new FileStream(KeptAsideName(slot), FileMode.Create, FileAccess.Write, FileShare.None);
stored.Position = 0;
stored.CopyTo(copy);
copy.Flush(flushToDisk: true);
return KeptAsideName(slot);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
_report(SettingsFault.NotKeptAside, exception);
return null;
}
}
/// <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>
/// 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 just kept
/// aside behind.
/// </summary>
private void Replace(byte[] content, FileStream? stored, string? aside)
{
string temporary = Path + "." + Guid.NewGuid().ToString("N") + ".tmp";
bool replaced = false;
@@ -239,16 +390,22 @@ public sealed class AppSettingsStore
stream.Write(content);
stream.Flush(flushToDisk: true);
}
if(exists)
{
string? aside = keepAside ? KeptAsideName() : null;
Retry(() => File.Replace(temporary, Path, aside, ignoreMetadataErrors: true));
}
else
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.
stored.Dispose();
Retry(() => File.Move(temporary, Path, overwrite: true));
}
}
replaced = true;
}
finally
@@ -256,26 +413,17 @@ public sealed class AppSettingsStore
if(!replaced)
{
TryDelete(temporary);
if(aside is not null)
{
TryDelete(aside);
}
}
}
}
/// <summary>The first free name for an invalid file; once every slot is used the last slot is reused.</summary>
private string KeptAsideName()
{
string candidate = Path + ".corrupt";
for(int slot = 2; slot <= MaximumKeptAside && File.Exists(candidate); slot++)
{
candidate = Path + ".corrupt-" + slot.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
return candidate;
}
/// <summary>A writer that died before its swap left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
/// <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) + ".";
@@ -310,12 +458,12 @@ public sealed class AppSettingsStore
/// <summary>A sharing or byte-range lock violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
private static bool IsTransient(IOException exception) => exception.HResult is SharingViolation or LockViolation;
/// <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(IOException exception) => exception.HResult == SharingViolation;
/// <summary>Cleanup only: a temporary file that cannot be removed now is removed by the next successful save.</summary>
/// <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
@@ -324,7 +472,7 @@ public sealed class AppSettingsStore
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
// The outcome of the save is already decided.
// Cleanup only: the operation this was part of has already succeeded or failed.
}
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
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(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.
byte[] name = Encoding.Unicode.GetBytes(destination);
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 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>
@@ -9,9 +9,9 @@ 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 the invalid file aside.</summary>
/// <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 values were invalid and read as their defaults; the next save keeps the original file aside.</summary>
/// <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,
@@ -19,4 +19,6 @@ public enum SettingsFault
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,
}
@@ -13,9 +13,11 @@ internal enum SettingsRead
Loaded,
/// <summary>No file yet; defaults apply and the first save creates it.</summary>
Missing,
/// <summary>Valid values were kept and invalid ones dropped; a save keeps the original file aside.</summary>
/// <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 the invalid file aside.</summary>
/// <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,
@@ -129,6 +129,11 @@ public partial class App
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;
}
@@ -196,6 +196,24 @@ public class AppSettingsStoreTests
/// <summary>A change that throws leaves the stored file as it was and open to every other program at once.</summary>
[Fact]
public void Update_ChangeThrows_ReleasesTheStoredFile()
{
using var temp = new TempDirectory();
Seed(temp);
byte[] before = File.ReadAllBytes(temp.File("settings.json"));
var store = new AppSettingsStore(temp.File("settings.json"));
Assert.Throws<InvalidOperationException>(() => store.Update(_ => throw new InvalidOperationException("change failed")));
using(new FileStream(temp.File("settings.json"), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
}
Assert.Equal(before, File.ReadAllBytes(temp.File("settings.json")));
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>Another handle holding the file for a moment (a scanner, a backup) is waited out, not read as an empty profile.</summary>
[Fact]
public void Load_FileLockedBriefly_RetriesAndReadsTheStoredValues()
@@ -358,7 +376,7 @@ public class AppSettingsStoreTests
Assert.Null(store.Update(settings => settings));
Assert.Equal("Catppuccin Mocha", store.Load().DocumentTheme);
}
Assert.Equal([SettingsFault.Unreadable, SettingsFault.Unreadable], faults);
Assert.Equal([SettingsFault.Unreadable], faults);
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
Assert.Equal(0, new FileInfo(temp.File("settings.json")).Length);
}
@@ -367,7 +385,6 @@ public class AppSettingsStoreTests
/// <summary>Content that is not settings at all yields defaults, is reported, and stays where it is until something is saved.</summary>
[Theory]
[InlineData("")]
[InlineData("{")]
[InlineData("null")]
[InlineData("[1]")]
@@ -403,24 +420,181 @@ public class AppSettingsStoreTests
/// <summary>One invalid value costs only that value: every other stored preference is kept.</summary>
/// <summary>An invalid property costs only that property: every other stored preference, before and after it, is kept.</summary>
[Fact]
public void Load_OneInvalidValue_KeepsEveryOtherValue()
public void Load_InvalidProperties_KeepsEveryOtherProperty()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("settings.json"), """{"Theme":"Dark","OutlineDepth":5.5,"DocumentTheme":"Nord","RecentFiles":["C:\\docs\\a.md",5],"GlobalCss":"body { color: red }","FutureSetting":{"a":1}}""");
File.WriteAllText(temp.File("settings.json"), """{"Theme":"Dark","OutlineDepth":5.5,"DocumentTheme":"Nord","RecentFiles":["C:\\docs\\a.md",5],"GlobalCss":"body { color: red }","FutureSetting":{"a":1},"WrapOutlineEntries":true,"ContentWidthPx":1234,"Frontmatter":"Metadata","RenderHighlight":false,"ShowSplashScreen":false,"OutlineWidth":300,"Print":{"Theme":"Nord","Borders":true}}""");
var faults = new List<SettingsFault>();
AppSettings loaded = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault)).Load();
Assert.Equal([SettingsFault.Repaired], faults);
Assert.Equal("Dark", loaded.Theme);
Assert.Equal("Nord", loaded.DocumentTheme);
Assert.Equal("body { color: red }", loaded.GlobalCss);
Assert.True(loaded.WrapOutlineEntries);
Assert.Equal(1234, loaded.ContentWidthPx);
Assert.Equal("Metadata", loaded.Frontmatter);
Assert.False(loaded.RenderHighlight);
Assert.False(loaded.ShowSplashScreen);
Assert.Equal(300, loaded.OutlineWidth);
Assert.Equal("Nord", loaded.Print.Theme);
Assert.True(loaded.Print.Borders);
Assert.Equal(3, loaded.OutlineDepth);
Assert.Empty(loaded.RecentFiles);
}
/// <summary>
/// Text the JSON reader cannot decode, an unpaired surrogate escape, is an invalid property
/// like any other: it is dropped, the rest load, and nothing is thrown into startup.
/// </summary>
[Fact]
public void LoadAndUpdate_UndecodableText_DropsThatPropertyAndSavesTheRest()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("settings.json"), "{\"DocumentTheme\":\"Nord\",\"GlobalCss\":\"a\\uD800b\",\"Theme\":\"Dark\"}");
var faults = new List<SettingsFault>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault));
AppSettings loaded = store.Load();
Assert.Equal("Nord", loaded.DocumentTheme);
Assert.Equal("Dark", loaded.Theme);
Assert.Equal("", loaded.GlobalCss);
Assert.NotNull(store.Update(settings => settings));
Assert.Equal([SettingsFault.Repaired, SettingsFault.Repaired], faults);
Assert.Equal("Dark", store.Load().Theme);
Assert.Equal(["settings.json", "settings.json.corrupt", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>A file with no content is not invalid settings: it reads as defaults, without a fault and without a wait.</summary>
[Fact]
public void Load_EmptyFile_ReturnsDefaultsWithoutAFaultOrAWait()
{
using var temp = new TempDirectory();
File.WriteAllBytes(temp.File("settings.json"), []);
int waits = 0;
var faults = new List<SettingsFault>();
Assert.Equal("Catppuccin Mocha", new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++).Load().DocumentTheme);
Assert.Empty(faults);
Assert.Equal(0, waits);
}
/// <summary>
/// A program that clears the file, closes it, and then writes it again leaves it empty for a
/// moment. A save waits for the content instead of filling the gap with defaults.
/// </summary>
[Fact]
public void Update_FileClearedThenRewritten_WaitsForTheContent()
{
using var temp = new TempDirectory();
Seed(temp);
File.WriteAllBytes(temp.File("settings.json"), []);
int waits = 0;
var faults = new List<SettingsFault>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ =>
{
if(++waits == 2)
{
File.WriteAllText(temp.File("settings.json"), """{"Theme":"Light","DocumentTheme":"Dracula"}""");
}
});
AppSettings? saved = store.Update(settings =>
{
settings.OutlineDepth = 5;
return settings;
});
Assert.NotNull(saved);
Assert.Equal(2, waits);
Assert.Empty(faults);
Assert.Equal("Dracula", store.Load().DocumentTheme);
Assert.Equal("Light", store.Load().Theme);
Assert.Equal(5, store.Load().OutlineDepth);
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>A file that stays empty is replaced after the wait, and nothing is kept aside: there is nothing to keep.</summary>
[Fact]
public void Update_FileStaysEmpty_SavesAfterTheWaitWithoutKeepingAnythingAside()
{
using var temp = new TempDirectory();
File.WriteAllBytes(temp.File("settings.json"), []);
int waits = 0;
var faults = new List<SettingsFault>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++);
Assert.NotNull(store.Update(settings =>
{
settings.Theme = "Dark";
return settings;
}));
Assert.Equal(AppSettingsStore.MaximumAttempts - 1, waits);
Assert.Empty(faults);
Assert.Equal("Dark", store.Load().Theme);
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>
/// A handle another program merely holds with write access does not delay a read for display:
/// the stored values are read beside it.
/// </summary>
[Fact]
public void Load_FileHeldWithWriteAccess_ReadsTheStoredValuesWithoutWaiting()
{
using var temp = new TempDirectory();
Seed(temp);
int waits = 0;
var faults = new List<SettingsFault>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++);
using(new FileStream(temp.File("settings.json"), FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete))
{
Assert.Equal("Nord", store.Load().DocumentTheme);
}
Assert.Equal(0, waits);
Assert.Empty(faults);
}
/// <summary>
/// Content read beside a writer that does not parse may simply be half written. The read waits
/// for the writer; if the content never becomes valid it is reported as unreadable, not as
/// corrupt, because it cannot be judged while someone is writing it.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Load_HalfWrittenContentBesideAWriter_WaitsAndNeverCallsItCorrupt(bool writerFinishes)
{
using var temp = new TempDirectory();
using var writer = new FileStream(temp.File("settings.json"), FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
writer.Write("""{"DocumentTheme":"Dra"""u8);
writer.Flush();
int waits = 0;
var faults = new List<SettingsFault>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ =>
{
if(++waits == 2 && writerFinishes)
{
writer.Write("""cula"}"""u8);
writer.Flush();
}
});
AppSettings loaded = store.Load();
Assert.Equal(writerFinishes ? "Dracula" : "Catppuccin Mocha", loaded.DocumentTheme);
Assert.Equal(writerFinishes ? 2 : AppSettingsStore.MaximumAttempts - 1, waits);
Assert.Equal(writerFinishes ? [] : [SettingsFault.Unreadable], faults);
}
/// <summary>A repeated property beside an invalid value is still read without throwing into startup.</summary>
[Fact]
public void Load_RepeatedPropertyAndInvalidValue_DoesNotThrow()
@@ -520,7 +694,144 @@ public class AppSettingsStoreTests
/// <summary>When the save over invalid content fails, the invalid file is still in place: it is never moved away first.</summary>
/// <summary>A folder where a kept-aside copy would go is skipped; the next name is used.</summary>
[Fact]
public void Update_KeptAsideNameIsAFolder_UsesTheNextName()
{
using var temp = new TempDirectory();
Directory.CreateDirectory(temp.File("settings.json.corrupt"));
File.WriteAllText(temp.File("settings.json"), "not json");
var faults = new List<SettingsFault>();
Assert.NotNull(new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault)).Update(settings => settings));
Assert.Equal("not json", File.ReadAllText(temp.File("settings.json.corrupt-2")));
Assert.Equal([SettingsFault.Corrupt], faults);
}
/// <summary>When no copy can be made the save still goes through, and the missing copy is reported.</summary>
[Fact]
public void Update_CopyCannotBeKeptAside_SavesAndReportsIt()
{
using var temp = new TempDirectory();
for(int slot = 2; slot <= AppSettingsStore.MaximumKeptAside; slot++)
{
Directory.CreateDirectory(temp.File("settings.json.corrupt-" + slot.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
Directory.CreateDirectory(temp.File("settings.json.corrupt"));
File.WriteAllText(temp.File("settings.json"), "not json");
var faults = new List<(SettingsFault Fault, Exception? Exception)>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, exception) => faults.Add((fault, exception)));
Assert.NotNull(store.Update(settings =>
{
settings.Theme = "Dark";
return settings;
}));
Assert.Equal([SettingsFault.Corrupt, SettingsFault.NotKeptAside], faults.Select(entry => entry.Fault));
Assert.IsType<UnauthorizedAccessException>(faults[1].Exception);
Assert.Equal("Dark", store.Load().Theme);
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>Kept-aside copies hold whatever the file held, so they can be removed on request; folders with those names are left.</summary>
[Fact]
public void DeleteKeptAside_CopiesAndAFolder_RemovesOnlyTheCopies()
{
using var temp = new TempDirectory();
Seed(temp);
File.WriteAllText(temp.File("settings.json.corrupt"), "copy 1");
File.WriteAllText(temp.File("settings.json.corrupt-2"), "copy 2");
File.WriteAllText(temp.File("settings.json.corrupt-9"), "copy 9");
File.WriteAllText(temp.File("settings.json.corrupt-10"), "not a kept-aside name");
Directory.CreateDirectory(temp.File("settings.json.corrupt-3"));
new AppSettingsStore(temp.File("settings.json")).DeleteKeptAside();
Assert.Equal(["settings.json", "settings.json.corrupt-10", "settings.json.lock"], FileNames(temp.Path));
Assert.True(Directory.Exists(temp.File("settings.json.corrupt-3")));
}
/// <summary>
/// The stored file is in place at the moment the new one is renamed over it and immediately
/// after: there is no step at which the folder has no settings file.
/// </summary>
[Fact]
public void Update_AtTheRename_TheStoredFileIsStillInPlace()
{
using var temp = new TempDirectory();
Seed(temp);
string path = temp.File("settings.json");
var observed = new List<string>();
var store = new AppSettingsStore(path, null, null, (source, destination) =>
{
observed.Add(File.Exists(destination) ? "stored file present before" : "stored file missing before");
observed.Add(File.Exists(source) ? "new file written" : "new file missing");
bool renamed = FileSwap.TryRenameOver(source, destination);
observed.Add(File.Exists(destination) ? "stored file present after" : "stored file missing after");
return renamed;
});
Assert.NotNull(store.Update(settings =>
{
settings.Theme = "Light";
return settings;
}));
Assert.Equal(["stored file present before", "new file written", "stored file present after"], observed);
Assert.Equal("Light", store.Load().Theme);
}
/// <summary>On a volume that cannot rename over an open file the store closes its own handle and replaces the file the older way.</summary>
[Fact]
public void Update_VolumeCannotRenameOverAnOpenFile_ReplacesAfterClosingItsHandle()
{
using var temp = new TempDirectory();
Seed(temp);
int attempts = 0;
var store = new AppSettingsStore(temp.File("settings.json"), null, _ => { }, (_, _) =>
{
attempts++;
return false;
});
Assert.NotNull(store.Update(settings =>
{
settings.Theme = "Light";
return settings;
}));
Assert.Equal(1, attempts);
Assert.Equal("Light", store.Load().Theme);
Assert.Equal("Nord", store.Load().DocumentTheme);
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>Only a sharing violation is waited out at the rename; any other I/O failure fails the save at once.</summary>
[Theory]
[InlineData(unchecked((int)0x80070020), 3, true)]
[InlineData(unchecked((int)0x80070021), 0, false)]
[InlineData(unchecked((int)0x8007001F), 0, false)]
[InlineData(0x00000020, 0, false)]
public void Update_RenameFails_WaitsOnlyForASharingViolation(int result, int expectedWaits, bool saved)
{
using var temp = new TempDirectory();
Seed(temp);
int waits = 0;
int attempts = 0;
var faults = new List<SettingsFault>();
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++, (source, destination) =>
++attempts <= 3 ? throw new IOException("refused", result) : FileSwap.TryRenameOver(source, destination));
Assert.Equal(saved, store.Update(settings => settings) is not null);
Assert.Equal(expectedWaits, waits);
Assert.Equal(saved ? [] : [SettingsFault.WriteFailed], faults);
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
}
/// <summary>When the save over invalid content fails, the invalid file is still in place and the copy made for this save is removed again.</summary>
[Fact]
public void Update_InvalidContentAndFailedSave_LeavesTheInvalidFileInPlace()
{
@@ -0,0 +1,120 @@
/*
* 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>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));
}