Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ on:
push:
branches: [ master ]
pull_request:
types: [closed]
branches: [ master ]

permissions:
contents: read
packages: write

jobs:
build:

Expand All @@ -18,13 +21,20 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install dependencies
- name: Restore
run: dotnet restore
- name: Pack solution
run: dotnet pack --configuration Release -o out --no-restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build --no-restore
- name: Pack library
run: dotnet pack src/WritableJsonConfiguration/WritableJsonConfiguration.csproj --configuration Release -o out --no-build --no-restore
- name: Push nuget packages to Nuget registry
if: github.event_name == 'push'
run: dotnet nuget push ./out/*.nupkg --skip-duplicate --no-symbols -k ${{secrets.NUGET_TOKEN}} -s https://api.nuget.org/v3/index.json
- name: Add GitHub registry as nuget source
if: github.event_name == 'push'
run: dotnet nuget add source https://nuget.pkg.github.com/kibnet/index.json --name github --username kibnet --password ${{secrets.GITHUB_TOKEN}} --store-password-in-clear-text
- name: Push nuget packages to GitHub registry
if: github.event_name == 'push'
run: dotnet nuget push ./out/*.nupkg --skip-duplicate --no-symbols -s "github"
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Changelog

All notable changes to this project are documented in this file.

## 8.1.0 - 2026-09-21

### Added

- Add opt-in atomic writes on Windows through `WritableJsonConfigurationSource.UseAtomicWrites`.
- Serialize in-process writes to the same settings path and keep one readable `.bak` copy.
- Preserve restrictive file permissions before writing settings bytes.

### Changed

- Publish configuration data in memory only after the file commit succeeds.
- Avoid rewriting and rotating the backup when a save does not change the JSON document.

### Fixed

- Preserve nested object siblings and array tails in atomic mode.
- Reconcile memory with disk after an ambiguous commit failure, or block further writes until the configuration root is recreated.

### Compatibility

- Atomic writes are disabled by default, so existing consumers retain the previous behavior.
- The package still targets `.NET Standard 2.0`.
- Explicit atomic mode is supported on Windows; other platforms fail before writing.

### Known limitations

- Atomic mode does not coordinate multiple processes or make a series of `Set` calls transactional.
- It cannot guarantee survival of arbitrary hardware or storage failures.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,38 @@ or
configuration.Set("Appearance:Theme", theme);
```

## Opt-in atomic writes on Windows

```csharp
IConfigurationRoot configuration = WritableJsonConfigurationFabric.Create(source =>
{
source.Path = "Settings.json";
source.Optional = true;
source.ReloadOnChange = false;
source.UseAtomicWrites = true;
source.ResolveFileProvider();
});
```

`UseAtomicWrites` defaults to `false`, preserving the previous API and platform behavior.
When enabled, both `Set` overloads serialize writes to the same physical path within
the process. A complete, parser-validated temporary file is flushed and replaces the
main file; `.bak` contains the previous readable version. A first save creates only
the main file. No-op saves do not rotate the backup. Memory is published after the
file commit; ambiguous I/O errors reread disk and block further writes if reconciliation
fails. Restart the application or recreate the configuration root before trying
again in that case; calling `Reload()` on the same root does not unblock writes.

Temporary files receive the source file's restricted Windows access permissions
before any settings bytes are written. Existing main/backup permission differences
that cannot safely be preserved cause an error, not a broader copy. The first file
uses the containing directory's permissions. Other operating systems reject explicit
opt-in with `PlatformNotSupportedException`; the default mode remains available.

This does not recover an already corrupt file, coordinate multiple processes, make a
series of `Set` calls transactional, or guarantee survival of arbitrary hardware
failure. Applications should validate/recover settings before loading configuration.
Backups and leftover temporary files may contain secrets and must not be published.

## Communication
Any suggestions and comments are welcome. If you want to contact me, use [Telegram](https://t.me/kibnet)
60 changes: 60 additions & 0 deletions WritableJsonConfiguration.CrashHarness/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using WritableJsonConfiguration;

if (args[0] == "benchmark")
{
var folder = Path.GetFullPath(args[1]);
Directory.CreateDirectory(folder);
for (int run = 0; run < 5; run++)
foreach (var atomic in run % 2 == 0 ? new[] { false, true } : new[] { true, false })
{
var path = Path.Combine(folder, $"{run}-" + (atomic ? "atomic.json" : "legacy.json"));
File.WriteAllText(path, System.Text.Json.JsonSerializer.Serialize(
Enumerable.Range(0, 100).ToDictionary(i => "Setting" + i, i => new string('x', 200))));
var startup = Stopwatch.StartNew();
using var configuration = Build(path, atomic);
startup.Stop();
var root = (IConfigurationRoot)configuration;
root["Counter"] = "warmup";
var save = Stopwatch.StartNew();
for (int i = 0; i < 100; i++) root["Counter"] = i.ToString();
save.Stop();
var noOp = Stopwatch.StartNew();
for (int i = 0; i < 100; i++) root["Counter"] = "99";
noOp.Stop();
Console.WriteLine($"run={run} {(atomic ? "atomic" : "legacy")}: bytes={new FileInfo(path).Length}, startup_ms={startup.Elapsed.TotalMilliseconds:F3}, 100_save_ms={save.Elapsed.TotalMilliseconds:F3}, 100_noop_ms={noOp.Elapsed.TotalMilliseconds:F3}");
}
return;
}

using var disposable = Build(Path.GetFullPath(args[0]), atomic: args[1] != "legacy");
var rootConfiguration = (IConfigurationRoot)disposable;
if (args[1] == "legacy")
{
var largeSyntheticValue = new string('x', 64 * 1024 * 1024);
Console.WriteLine("checkpoint:legacy-ready");
Console.Out.Flush();
rootConfiguration["Theme"] = largeSyntheticValue;
return;
}
var provider = (WritableJsonConfigurationProvider)rootConfiguration.Providers.Single();
var requestedStage = Enum.Parse<AtomicWriteStage>(args[1]);
provider.AtomicWriteCheckpoint = (stage, _) =>
{
if (stage != requestedStage) return;
Console.WriteLine("checkpoint:" + stage);
Console.Out.Flush();
Thread.Sleep(Timeout.Infinite);
};
rootConfiguration["Theme"] = "new";
throw new InvalidOperationException("The requested checkpoint was not reached.");

static IDisposable Build(string path, bool atomic) => (IDisposable)WritableJsonConfigurationFabric.Create(source =>
{
source.Path = path;
source.Optional = false;
source.ReloadOnChange = false;
source.UseAtomicWrites = atomic;
source.ResolveFileProvider();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../src/WritableJsonConfiguration/WritableJsonConfiguration.csproj" />
</ItemGroup>
</Project>
113 changes: 113 additions & 0 deletions WritableJsonConfiguration.Tests/AtomicCrashTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using TheoryAttribute = WritableJsonConfiguration.Tests.WindowsTheoryAttribute;
using FactAttribute = WritableJsonConfiguration.Tests.WindowsFactAttribute;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Runtime.Versioning;

namespace WritableJsonConfiguration.Tests;

[SupportedOSPlatform("windows")]
public class AtomicCrashTests
{
[Fact]
public async Task RetainedLegacyWriterCanLeaveTruncatedJsonWhenKilledDuringWrite()
{
var directory = Path.Combine(Path.GetTempPath(), "WritableJsonLegacy-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "Settings.json");
File.WriteAllText(path, "{\"Theme\":\"old\"}");
var originalLength = new FileInfo(path).Length;
using var process = CreateProcess(path, "legacy");
try
{
Assert.True(process.Start());
Assert.Equal("checkpoint:legacy-ready", await process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(20)));
var timer = Stopwatch.StartNew();
bool sawTruncatedWrite = false;
while (timer.Elapsed < TimeSpan.FromSeconds(20) && !process.HasExited)
{
var length = new FileInfo(path).Length;
if (length != originalLength && length < 64 * 1024 * 1024)
{
sawTruncatedWrite = true;
process.Kill(entireProcessTree: true);
break;
}
await Task.Delay(1);
}
Assert.True(sawTruncatedWrite, "The legacy write boundary was not observed; do not count this as reproduced corruption.");
await process.WaitForExitAsync();
Assert.ThrowsAny<Exception>(() => WritableJsonConfigurationFabric.Create(path, reloadOnChange: false, optional: false));
Assert.False(File.Exists(path + ".bak"));
}
finally
{
if (!process.HasExited) { process.Kill(true); process.WaitForExit(); }
Directory.Delete(directory, recursive: true);
}
}

[Theory]
[InlineData("PermissionsApplied", "old", false)]
[InlineData("BeforeFlush", "old", false)]
[InlineData("BeforeCommit", "old", false)]
[InlineData("AfterCommit", "new", true)]
public async Task KillingWriterLeavesACompleteOldOrNewConfiguration(string checkpoint, string expected, bool hasBackup)
{
var directory = Path.Combine(Path.GetTempPath(), "WritableJsonCrash-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "Settings.json");
var original = "{\"Theme\":\"old\",\"Unknown\":\"preserved\"}";
File.WriteAllText(path, original);
var permissions = new FileSecurity();
permissions.SetAccessRuleProtection(true, false);
permissions.AddAccessRule(new FileSystemAccessRule(WindowsIdentity.GetCurrent().User!, FileSystemRights.FullControl, AccessControlType.Allow));
new FileInfo(path).SetAccessControl(permissions);
var expectedAcl = new FileInfo(path).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorSddlForm(AccessControlSections.Access);
using var process = CreateProcess(path, checkpoint);
try
{
Assert.True(process.Start());
var line = await process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal("checkpoint:" + checkpoint, line);
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync();
using var reloaded = (IDisposable)WritableJsonConfigurationFabric.Create(path, reloadOnChange: false, optional: false);
var root = (IConfigurationRoot)reloaded;
Assert.Equal(expected, root["Theme"]);
Assert.Equal("preserved", root["Unknown"]);
Assert.Equal(hasBackup, File.Exists(path + ".bak"));
if (hasBackup) Assert.Equal(original, File.ReadAllText(path + ".bak"));
else Assert.Equal(original, File.ReadAllText(path));
foreach (var file in Directory.EnumerateFiles(directory))
Assert.Equal(expectedAcl, new FileInfo(file).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorSddlForm(AccessControlSections.Access));
}
finally
{
if (process.Id != 0 && !process.HasExited) { process.Kill(true); process.WaitForExit(); }
Directory.Delete(directory, recursive: true);
}
}

private static Process CreateProcess(string path, string checkpoint)
{
var process = new Process
{
StartInfo = new ProcessStartInfo("dotnet")
{
UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
}
};
var configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent!.Name;
var repository = new DirectoryInfo(AppContext.BaseDirectory);
while (!File.Exists(Path.Combine(repository.FullName, "WritableJsonConfiguration.sln")))
repository = repository.Parent ?? throw new InvalidOperationException("Repository not found.");
process.StartInfo.ArgumentList.Add(Path.Combine(repository.FullName, "WritableJsonConfiguration.CrashHarness", "bin", configuration, "net8.0", "WritableJsonConfiguration.CrashHarness.dll"));
process.StartInfo.ArgumentList.Add(path);
process.StartInfo.ArgumentList.Add(checkpoint);
return process;
}
}
Loading
Loading