diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 25464a1..1b46bd9 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -4,9 +4,12 @@ on: push: branches: [ master ] pull_request: - types: [closed] branches: [ master ] +permissions: + contents: read + packages: write + jobs: build: @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e1824a --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index cee095f..b1a6ec9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/WritableJsonConfiguration.CrashHarness/Program.cs b/WritableJsonConfiguration.CrashHarness/Program.cs new file mode 100644 index 0000000..1325494 --- /dev/null +++ b/WritableJsonConfiguration.CrashHarness/Program.cs @@ -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(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(); +}); diff --git a/WritableJsonConfiguration.CrashHarness/WritableJsonConfiguration.CrashHarness.csproj b/WritableJsonConfiguration.CrashHarness/WritableJsonConfiguration.CrashHarness.csproj new file mode 100644 index 0000000..6fc0f84 --- /dev/null +++ b/WritableJsonConfiguration.CrashHarness/WritableJsonConfiguration.CrashHarness.csproj @@ -0,0 +1,12 @@ + + + net8.0 + Exe + enable + enable + false + + + + + diff --git a/WritableJsonConfiguration.Tests/AtomicCrashTests.cs b/WritableJsonConfiguration.Tests/AtomicCrashTests.cs new file mode 100644 index 0000000..2334d41 --- /dev/null +++ b/WritableJsonConfiguration.Tests/AtomicCrashTests.cs @@ -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(() => 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; + } +} diff --git a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs new file mode 100644 index 0000000..6927b28 --- /dev/null +++ b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs @@ -0,0 +1,436 @@ +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json.Linq; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Runtime.Versioning; +using FactAttribute = WritableJsonConfiguration.Tests.WindowsFactAttribute; +using TheoryAttribute = WritableJsonConfiguration.Tests.WindowsTheoryAttribute; + +namespace WritableJsonConfiguration.Tests; + +[SupportedOSPlatform("windows")] +public class AtomicWriteTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "WritableJsonTests-" + Guid.NewGuid().ToString("N")); + private string SettingsPath => Path.Combine(directory, "Settings.json"); + + public AtomicWriteTests() => Directory.CreateDirectory(directory); + + private IConfigurationRoot Create(bool atomic = true) + { + return WritableJsonConfigurationFabric.Create(source => + { + source.Path = SettingsPath; + source.Optional = true; + source.ReloadOnChange = false; + source.ResolveFileProvider(); + source.UseAtomicWrites = atomic; + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FailedSaveDoesNotPublishMemory(bool objectOverload) + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + using var locked = new FileStream(SettingsPath, FileMode.Open, FileAccess.Read, FileShare.Read); + Assert.ThrowsAny(() => + { + if (objectOverload) provider.Set("Theme", (object)"new"); + else ((IConfigurationProvider)provider).Set("Theme", "new"); + }); + Assert.Equal("old", configuration["Theme"]); + Assert.Equal("old", JObject.Parse(File.ReadAllText(SettingsPath))["Theme"]!.Value()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExistingApiPreservesUnknownValuesAndLegacyMerge(bool atomic) + { + File.WriteAllText(SettingsPath, "{\"Unknown\":17,\"Enabled\":true,\"Null\":null,\"Items\":[\"a\",\"b\"]}"); + using var root = (IDisposable)Create(atomic); + var configuration = (IConfigurationRoot)root; + configuration.Set("Items", new[] { "changed" }); + configuration.Set("Options", new { Flag = true, Count = 42, Empty = (string?)null }); + Assert.Equal(new[] { "changed", "b" }, configuration.Get("Items")); + Assert.True(configuration.Get("Options:Flag")); + Assert.Equal(42, configuration.Get("Options:Count")); + Assert.Equal("", configuration["Options:Empty"]); + var disk = JObject.Parse(File.ReadAllText(SettingsPath)); + Assert.Equal(17, disk["Unknown"]!.Value()); + Assert.Equal(JTokenType.Boolean, disk["Enabled"]!.Type); + Assert.Equal(JTokenType.Null, disk["Null"]!.Type); + Assert.Equal(JTokenType.String, disk["Options"]!["Count"]!.Type); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AtomicDeepUpdatesPreserveUnknownSiblingsOnDiskAndAfterReload(bool objectOverload) + { + File.WriteAllText(SettingsPath, "{\"A\":{\"B\":{\"One\":\"1\",\"Two\":\"2\"},\"Unknown\":true},\"Outside\":17}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + if (objectOverload) configuration.Set("A:B", new { One = "x" }); + else configuration["A:B:One"] = "x"; + Assert.Equal("x", configuration["A:B:One"]); + Assert.Equal("2", configuration["A:B:Two"]); + using var reloaded = (IDisposable)Create(); + Assert.Equal("2", ((IConfigurationRoot)reloaded)["A:B:Two"]); + var disk = JObject.Parse(File.ReadAllText(SettingsPath)); + Assert.True(disk["A"]!["Unknown"]!.Value()); + Assert.Equal(17, disk["Outside"]!.Value()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AtomicNestedArrayUpdatesPreserveOtherFieldsAndTail(bool objectOverload) + { + File.WriteAllText(SettingsPath, "{\"A\":{\"B\":{\"Items\":[{\"One\":\"1\",\"Two\":\"2\"},{\"Tail\":\"kept\"}],\"Unknown\":7}}}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + if (objectOverload) configuration.Set("A:B:Items", new[] { new { One = "x" } }); + else configuration["A:B:Items:0:One"] = "x"; + Assert.Equal("x", configuration["A:B:Items:0:One"]); + Assert.Equal("2", configuration["A:B:Items:0:Two"]); + Assert.Equal("kept", configuration["A:B:Items:1:Tail"]); + Assert.Equal("7", configuration["A:B:Unknown"]); + using var reloaded = (IDisposable)Create(); + Assert.Equal("2", ((IConfigurationRoot)reloaded)["A:B:Items:0:Two"]); + Assert.Equal("kept", ((IConfigurationRoot)reloaded)["A:B:Items:1:Tail"]); + } + + [Fact] + public void DefaultRemainsLegacyAndDoesNotCreateBackup() + { + Assert.False(new WritableJsonConfigurationSource().UseAtomicWrites); + using var root = (IDisposable)Create(atomic: false); + var configuration = (IConfigurationRoot)root; + configuration["Theme"] = "first"; + configuration["Theme"] = "second"; + Assert.False(File.Exists(SettingsPath + ".bak")); + } + + [Fact] + public void FirstSaveCreatesMainThenSecondCreatesExactPreviousBackupAndNoOpLeavesBothUntouched() + { + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + configuration["Theme"] = "first"; + Assert.False(File.Exists(SettingsPath + ".bak")); + var first = File.ReadAllBytes(SettingsPath); + configuration["Theme"] = "second"; + Assert.Equal(first, File.ReadAllBytes(SettingsPath + ".bak")); + var mainTime = File.GetLastWriteTimeUtc(SettingsPath); + var backupTime = File.GetLastWriteTimeUtc(SettingsPath + ".bak"); + configuration["Theme"] = "second"; + Assert.Equal(mainTime, File.GetLastWriteTimeUtc(SettingsPath)); + Assert.Equal(backupTime, File.GetLastWriteTimeUtc(SettingsPath + ".bak")); + Assert.Equal(first, File.ReadAllBytes(SettingsPath + ".bak")); + } + + [Fact] + public async Task DifferentProvidersSerializeEntireReadModifyWrite() + { + File.WriteAllText(SettingsPath, "{\"Unknown\":\"kept\"}"); + using var left = (IDisposable)Create(); + using var right = (IDisposable)Create(); + await Task.WhenAll(Enumerable.Range(0, 40).Select(index => Task.Run(() => + { + var root = (IConfigurationRoot)(index % 2 == 0 ? left : right); + if (index % 2 == 0) root["Key" + index] = index.ToString(); + else root.Set("Key" + index, index); + }))); + using var reloaded = (IDisposable)Create(); + for (int index = 0; index < 40; index++) + Assert.Equal(index.ToString(), ((IConfigurationRoot)reloaded)["Key" + index]); + Assert.Equal("kept", ((IConfigurationRoot)reloaded)["Unknown"]); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void InjectedPrecommitFailureLeavesMainBackupAndMemoryUnchanged(int stageValue) + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + File.Copy(SettingsPath, SettingsPath + ".bak"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + var before = File.ReadAllBytes(SettingsPath); + provider.AtomicWriteCheckpoint = (stage, temporary) => + { + if (stage != (AtomicWriteStage)stageValue) return; + if (stage == AtomicWriteStage.PermissionsApplied) Assert.Equal(0, new FileInfo(temporary).Length); + throw new IOException("injected failure"); + }; + Assert.Throws(() => configuration["Theme"] = "new"); + Assert.Equal(before, File.ReadAllBytes(SettingsPath)); + Assert.Equal(before, File.ReadAllBytes(SettingsPath + ".bak")); + Assert.Equal("old", configuration["Theme"]); + Assert.Empty(Directory.GetFiles(directory, "*.tmp-*")); + } + + [Fact] + public void AmbiguousPostcommitErrorReconcilesMemoryWithActualDisk() + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + provider.AtomicWriteCheckpoint = (stage, _) => + { + if (stage == AtomicWriteStage.AfterCommit) throw new IOException("ambiguous commit"); + }; + Assert.Throws(() => configuration["Theme"] = "new"); + Assert.Equal("new", configuration["Theme"]); + Assert.Equal("new", JObject.Parse(File.ReadAllText(SettingsPath))["Theme"]!.Value()); + Assert.Equal("old", JObject.Parse(File.ReadAllText(SettingsPath + ".bak"))["Theme"]!.Value()); + provider.AtomicWriteCheckpoint = null; + configuration["Theme"] = "next"; + Assert.Equal("next", configuration["Theme"]); + } + + [Fact] + public void UnreconciledFailureBlocksFurtherWritesEvenAfterExternalRepair() + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + provider.AtomicWriteCheckpoint = (stage, _) => + { + if (stage != AtomicWriteStage.BeforeCommit) return; + File.WriteAllText(SettingsPath, ""); + throw new IOException("ambiguous external failure"); + }; + Assert.Throws(() => configuration["Theme"] = "new"); + File.WriteAllText(SettingsPath, "{\"Theme\":\"repaired\"}"); + provider.AtomicWriteCheckpoint = null; + Assert.Throws(() => configuration["Theme"] = "retry"); + Assert.Equal("repaired", JObject.Parse(File.ReadAllText(SettingsPath))["Theme"]!.Value()); + } + + [Theory] + [InlineData("")] + [InlineData("{\"Theme\":" )] + [InlineData("[]")] + [InlineData("{\"duplicate\":1,\"DUPLICATE\":2}")] + public void CorruptionDuringRuntimeIsNotOverwritten(string invalidJson) + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + using var root = (IDisposable)Create(); + File.WriteAllText(SettingsPath, invalidJson); + Assert.ThrowsAny(() => ((IConfigurationRoot)root)["Theme"] = "new"); + Assert.Equal(invalidJson, File.ReadAllText(SettingsPath)); + Assert.False(File.Exists(SettingsPath + ".bak")); + } + + [Fact] + public void CandidateMustBeAcceptedByStartupParser() + { + File.WriteAllText(SettingsPath, "{\"theme\":\"old\"}"); + using var root = (IDisposable)Create(); + var before = File.ReadAllBytes(SettingsPath); + Assert.ThrowsAny(() => ((IConfigurationRoot)root)["THEME"] = "new"); + Assert.Equal(before, File.ReadAllBytes(SettingsPath)); + Assert.Equal("old", ((IConfigurationRoot)root)["theme"]); + } + + [Fact] + public void ParserFailureDoesNotExposeSettingsThroughExceptionChain() + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + using var root = (IDisposable)Create(); + File.WriteAllText(SettingsPath, "{\"synthetic-secret-marker\":1,\"SYNTHETIC-SECRET-MARKER\":2}"); + var error = Assert.Throws(() => ((IConfigurationRoot)root)["Theme"] = "new"); + Assert.DoesNotContain("secret-marker", error.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Null(error.InnerException); + } + + [Fact] + public void CopiesReceiveRestrictiveMainAclBeforeAnyBytes() + { + File.WriteAllText(SettingsPath, "{\"Secret\":\"synthetic-only\"}"); + Restrict(SettingsPath); + var expected = Permissions(SettingsPath); + Assert.NotEqual(expected, Permissions(directory, isDirectory: true)); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + var inspected = false; + provider.AtomicWriteCheckpoint = (stage, temporary) => + { + if (stage != AtomicWriteStage.PermissionsApplied) return; + inspected = true; + Assert.Equal(0, new FileInfo(temporary).Length); + Assert.Equal(expected, Permissions(temporary)); + }; + configuration["Theme"] = "new"; + Assert.True(inspected); + Assert.Equal(expected, Permissions(SettingsPath)); + Assert.Equal(expected, Permissions(SettingsPath + ".bak")); + } + + [Fact] + public void PersistedProtectedBootstrapBackupAllowsSuccessiveSavesOfInheritedMain() + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + Assert.False(new FileInfo(SettingsPath).GetAccessControl().AreAccessRulesProtected); + var bootstrapPermissions = new FileInfo(SettingsPath).GetAccessControl(AccessControlSections.Access); + bootstrapPermissions.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + var temporary = SettingsPath + ".bootstrap"; + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + new FileInfo(temporary).SetAccessControl(bootstrapPermissions); + stream.Write(File.ReadAllBytes(SettingsPath)); + stream.Flush(true); + } + File.Move(temporary, SettingsPath + ".bak"); + Assert.True(AtomicSettingsFile.HaveEquivalentAccess( + new FileInfo(SettingsPath).GetAccessControl(AccessControlSections.Access), + new FileInfo(SettingsPath + ".bak").GetAccessControl(AccessControlSections.Access))); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + configuration["Theme"] = "first"; + configuration["Theme"] = "second"; + configuration["Theme"] = "third"; + Assert.Equal("third", configuration["Theme"]); + Assert.Equal("second", JObject.Parse(File.ReadAllText(SettingsPath + ".bak"))["Theme"]!.Value()); + } + + [Fact] + public void AccessComparisonIgnoresOnlyProvenanceAndOrderWithinSameKind() + { + var inherited = Security("D:AI(A;ID;FR;;;SY)(A;ID;FA;;;BA)"); + var explicitReversed = Security("D:P(A;;FA;;;BA)(A;;FR;;;SY)"); + Assert.True(AtomicSettingsFile.HaveEquivalentAccess(inherited, explicitReversed)); + Assert.False(AtomicSettingsFile.HaveEquivalentAccess(inherited, Security("D:P(A;;FA;;;BA)(A;;FA;;;SY)"))); + Assert.False(AtomicSettingsFile.HaveEquivalentAccess(inherited, Security("D:P(A;;FA;;;BA)(A;;FR;;;WD)"))); + Assert.False(AtomicSettingsFile.HaveEquivalentAccess(inherited, Security("D:P(A;;FA;;;BA)(A;IO;FR;;;SY)"))); + } + + [Fact] + public void AccessComparisonDoesNotIgnoreAllowDenyOrderOrMergeGrantSets() + { + Assert.False(AtomicSettingsFile.HaveEquivalentAccess( + Security("D:(A;;FR;;;SY)(D;;FR;;;SY)"), Security("D:(D;;FR;;;SY)(A;;FR;;;SY)"))); + Assert.False(AtomicSettingsFile.HaveEquivalentAccess(Security("D:"), Security("D:(A;;FA;;;WD)"))); + } + + [Fact] + public void AccessComparisonPreservesUnsupportedCallbackAceExactly() + { + Assert.True(AtomicSettingsFile.HaveEquivalentAccess(CallbackSecurity(AceFlags.Inherited), CallbackSecurity(AceFlags.Inherited))); + Assert.False(AtomicSettingsFile.HaveEquivalentAccess(CallbackSecurity(AceFlags.Inherited), CallbackSecurity(AceFlags.None))); + } + + private static FileSecurity CallbackSecurity(AceFlags flags) + { + var acl = new RawAcl(GenericAcl.AclRevision, 1); + acl.InsertAce(0, new CommonAce(flags, AceQualifier.AccessAllowed, 1, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), true, new byte[4])); + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + var bytes = new byte[descriptor.BinaryLength]; + descriptor.GetBinaryForm(bytes, 0); + var security = new FileSecurity(); + security.SetSecurityDescriptorBinaryForm(bytes, AccessControlSections.Access); + return security; + } + + private static FileSecurity Security(string sddl) + { + var security = new FileSecurity(); + security.SetSecurityDescriptorSddlForm(sddl, AccessControlSections.Access); + return security; + } + + [Fact] + public void RestrictiveExistingBackupIsNeverReplacedWithBroaderAcl() + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + File.Copy(SettingsPath, SettingsPath + ".bak"); + Restrict(SettingsPath + ".bak"); + var expected = Permissions(SettingsPath + ".bak"); + using var root = (IDisposable)Create(); + Assert.Throws(() => ((IConfigurationRoot)root)["Theme"] = "new"); + Assert.Equal(expected, Permissions(SettingsPath + ".bak")); + Assert.Equal("old", ((IConfigurationRoot)root)["Theme"]); + Assert.Empty(Directory.GetFiles(directory, "*.tmp-*")); + } + + [Fact] + public void PermissionFailureOccursBeforeConfidentialBytes() + { + File.WriteAllText(SettingsPath, "{\"Secret\":\"synthetic-only\"}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + var before = File.ReadAllBytes(SettingsPath); + provider.AtomicWriteCheckpoint = (stage, temporary) => + { + if (stage != AtomicWriteStage.PermissionsApplied) return; + Assert.Equal(0, new FileInfo(temporary).Length); + throw new UnauthorizedAccessException("injected permission failure"); + }; + Assert.Throws(() => configuration["Secret"] = "new"); + Assert.Equal(before, File.ReadAllBytes(SettingsPath)); + Assert.Empty(Directory.GetFiles(directory, "*.tmp-*")); + } + + [Fact] + public void LockedBackupMakesReplacementFailWithoutDiscardingEitherFile() + { + File.WriteAllText(SettingsPath, "{\"Theme\":\"old\"}"); + File.Copy(SettingsPath, SettingsPath + ".bak"); + using var root = (IDisposable)Create(); + using var locked = new FileStream(SettingsPath + ".bak", FileMode.Open, FileAccess.Read, FileShare.Read); + var before = File.ReadAllBytes(SettingsPath); + Assert.ThrowsAny(() => ((IConfigurationRoot)root)["Theme"] = "new"); + Assert.Equal(before, File.ReadAllBytes(SettingsPath)); + Assert.Equal(before, File.ReadAllBytes(SettingsPath + ".bak")); + Assert.Equal("old", ((IConfigurationRoot)root)["Theme"]); + } + + [Fact] + public void FirstSaveNeverOverwritesAFileCreatedByAnotherWriter() + { + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + var provider = (WritableJsonConfigurationProvider)configuration.Providers.Single(); + provider.AtomicWriteCheckpoint = (stage, _) => + { + if (stage == AtomicWriteStage.BeforeCommit) File.WriteAllText(SettingsPath, "{\"Other\":\"kept\"}"); + }; + Assert.Throws(() => configuration["Theme"] = "new"); + Assert.Equal("{\"Other\":\"kept\"}", File.ReadAllText(SettingsPath)); + Assert.Equal("kept", configuration["Other"]); + Assert.Null(configuration["Theme"]); + } + + private static void Restrict(string path) + { + var acl = new FileSecurity(); + acl.SetAccessRuleProtection(true, false); + acl.AddAccessRule(new FileSystemAccessRule(WindowsIdentity.GetCurrent().User!, FileSystemRights.FullControl, AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(acl); + } + + private static string Permissions(string path, bool isDirectory = false) + { + FileSystemSecurity acl = isDirectory + ? new DirectoryInfo(path).GetAccessControl(AccessControlSections.Access) + : new FileInfo(path).GetAccessControl(AccessControlSections.Access); + acl.SetAccessRuleProtection(true, true); + return acl.GetSecurityDescriptorSddlForm(AccessControlSections.Access); + } + + public void Dispose() => Directory.Delete(directory, recursive: true); +} diff --git a/WritableJsonConfiguration.Tests/PlatformAttributes.cs b/WritableJsonConfiguration.Tests/PlatformAttributes.cs new file mode 100644 index 0000000..dd09fda --- /dev/null +++ b/WritableJsonConfiguration.Tests/PlatformAttributes.cs @@ -0,0 +1,37 @@ +namespace WritableJsonConfiguration.Tests; + +public sealed class WindowsFactAttribute : FactAttribute +{ + public WindowsFactAttribute() + { + if (!OperatingSystem.IsWindows()) Skip = "Atomic writes are Windows-only."; + } +} + +public sealed class WindowsTheoryAttribute : TheoryAttribute +{ + public WindowsTheoryAttribute() + { + if (!OperatingSystem.IsWindows()) Skip = "Atomic writes are Windows-only."; + } +} + +public sealed class NonWindowsFactAttribute : FactAttribute +{ + public NonWindowsFactAttribute() + { + if (OperatingSystem.IsWindows()) Skip = "Requires a non-Windows runtime."; + } +} + +public class PlatformContractTests +{ + [NonWindowsFact] + public void ExplicitOptInIsRejectedBeforeAnyWriteOnOtherPlatforms() + { + var source = new WritableJsonConfigurationSource { UseAtomicWrites = true }; + Assert.Throws(() => new WritableJsonConfigurationProvider(source)); + source.UseAtomicWrites = false; + Assert.NotNull(new WritableJsonConfigurationProvider(source)); + } +} diff --git a/WritableJsonConfiguration.Tests/WritableJsonConfiguration.Tests.csproj b/WritableJsonConfiguration.Tests/WritableJsonConfiguration.Tests.csproj index cf49e22..52da166 100644 --- a/WritableJsonConfiguration.Tests/WritableJsonConfiguration.Tests.csproj +++ b/WritableJsonConfiguration.Tests/WritableJsonConfiguration.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/WritableJsonConfiguration.sln b/WritableJsonConfiguration.sln index 6a8f8a4..ba78add 100644 --- a/WritableJsonConfiguration.sln +++ b/WritableJsonConfiguration.sln @@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WritableJsonConfiguration.Tests", "WritableJsonConfiguration.Tests\WritableJsonConfiguration.Tests.csproj", "{F8019035-BA23-4092-9C3E-D1E9927D6EB7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WritableJsonConfiguration.CrashHarness", "WritableJsonConfiguration.CrashHarness\WritableJsonConfiguration.CrashHarness.csproj", "{99540A0F-52C9-4C10-83D2-66B55009FAC6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {F8019035-BA23-4092-9C3E-D1E9927D6EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8019035-BA23-4092-9C3E-D1E9927D6EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU {F8019035-BA23-4092-9C3E-D1E9927D6EB7}.Release|Any CPU.Build.0 = Release|Any CPU + {99540A0F-52C9-4C10-83D2-66B55009FAC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99540A0F-52C9-4C10-83D2-66B55009FAC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99540A0F-52C9-4C10-83D2-66B55009FAC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99540A0F-52C9-4C10-83D2-66B55009FAC6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/WritableJsonConfiguration/AssemblyInfo.cs b/src/WritableJsonConfiguration/AssemblyInfo.cs new file mode 100644 index 0000000..f26a608 --- /dev/null +++ b/src/WritableJsonConfiguration/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WritableJsonConfiguration.Tests")] +[assembly: InternalsVisibleTo("WritableJsonConfiguration.CrashHarness")] diff --git a/src/WritableJsonConfiguration/AtomicSettingsFile.cs b/src/WritableJsonConfiguration/AtomicSettingsFile.cs new file mode 100644 index 0000000..779466a --- /dev/null +++ b/src/WritableJsonConfiguration/AtomicSettingsFile.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Text; + +namespace WritableJsonConfiguration +{ + internal enum AtomicWriteStage { PermissionsApplied, BeforeFlush, BeforeCommit, AfterCommit } + + internal static class AtomicSettingsFile + { + internal static void EnsureSupported() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + throw new PlatformNotSupportedException("Atomic configuration writes are supported only on Windows."); + } + + internal static void Write(string path, string json, bool exists, Action checkpoint) + { + var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N"); + var backup = path + ".bak"; + var permissions = exists ? ReadPermissions(path) : null; + if (exists && File.Exists(backup) && + !HaveEquivalentAccess(permissions, ReadPermissions(backup))) + throw new IOException("Configuration backup permissions differ; refusing to broaden access."); + + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + // The file is still empty: do not place secrets under inherited directory ACLs. + if (permissions != null) new FileInfo(temporary).SetAccessControl(permissions); + checkpoint?.Invoke(AtomicWriteStage.PermissionsApplied, temporary); + using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, leaveOpen: true)) + { + writer.Write(json); + } + checkpoint?.Invoke(AtomicWriteStage.BeforeFlush, temporary); + stream.Flush(flushToDisk: true); + } + checkpoint?.Invoke(AtomicWriteStage.BeforeCommit, temporary); + if (exists) File.Replace(temporary, path, backup); + else File.Move(temporary, path); + checkpoint?.Invoke(AtomicWriteStage.AfterCommit, path); + } + finally + { + // Never remove another writer's temp, the main file, or a usable backup. + try { File.Delete(temporary); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private static FileSecurity ReadPermissions(string path) + { + var security = new FileInfo(path).GetAccessControl(AccessControlSections.Access); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + return security; + } + + internal static bool HaveEquivalentAccess(FileSecurity left, FileSecurity right) + { + return AccessFingerprint(left) == AccessFingerprint(right); + } + + private static string AccessFingerprint(FileSecurity security) + { + var descriptor = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0); + var dacl = descriptor.DiscretionaryAcl; + if (dacl == null) return "null-dacl"; + var result = new StringBuilder(); + var group = new List(); + int previousType = -1; + for (int index = 0; index < dacl.Count; index++) + { + var ace = dacl[index]; + // Windows removes inherited provenance and reorders allow ACEs when persisting + // a protected copy. Neither changes access. Never reorder deny across allow: + // their relative position can change effective permissions. + var common = ace as CommonAce; + bool canReorder = common != null && !common.IsCallback && + (common.AceQualifier == AceQualifier.AccessAllowed || common.AceQualifier == AceQualifier.AccessDenied); + int type = canReorder ? (int)ace.AceType : 256 + index; + if (type != previousType) + { + AppendGroup(result, group, previousType); + previousType = type; + } + var bytes = new byte[ace.BinaryLength]; + ace.GetBinaryForm(bytes, 0); + var normalized = GenericAce.CreateFromBinaryForm(bytes, 0); + if (canReorder) normalized.AceFlags &= ~AceFlags.Inherited; + normalized.GetBinaryForm(bytes, 0); + group.Add(Convert.ToBase64String(bytes)); + } + AppendGroup(result, group, previousType); + return result.ToString(); + } + + private static void AppendGroup(StringBuilder result, List group, int type) + { + if (group.Count == 0) return; + group.Sort(StringComparer.Ordinal); + result.Append(type).Append(':').Append(string.Join(",", group)).Append(';'); + group.Clear(); + } + } +} diff --git a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj index 5a1e666..497618d 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj +++ b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj @@ -5,9 +5,12 @@ Kibnet https://github.com/Kibnet/WritableJsonConfiguration https://github.com/Kibnet/WritableJsonConfiguration - 8.0.1 + 8.1.0 Source of configurations in JSON format with the ability to edit values directly from the running application. Based on Microsoft.Extensions.Configuration. JSON_logo.png + README.md + MIT + Add opt-in atomic Windows writes with durable replacement, one backup, serialized in-process updates, and fail-closed permission handling. @@ -15,6 +18,7 @@ + @@ -22,6 +26,7 @@ True + diff --git a/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs b/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs index 4b4a0c2..cb2eb96 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs +++ b/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs @@ -1,5 +1,8 @@ using System; using System.IO; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text; using Microsoft.Extensions.Configuration.Json; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -8,9 +11,17 @@ namespace WritableJsonConfiguration { public class WritableJsonConfigurationProvider : JsonConfigurationProvider { + private static readonly ConcurrentDictionary PathLocks = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly bool useAtomicWrites; + private bool writesBlocked; + internal Action AtomicWriteCheckpoint { get; set; } + // Конструктор класса, наследуемого от JsonConfigurationProvider public WritableJsonConfigurationProvider(JsonConfigurationSource source) : base(source) { + useAtomicWrites = (source as WritableJsonConfigurationSource)?.UseAtomicWrites == true; + if (useAtomicWrites) AtomicSettingsFile.EnsureSupported(); } // Метод для сохранения JSON-объекта в файл @@ -25,10 +36,10 @@ private void Save(dynamic jsonObj) } // Установка значения по ключу в JSON-объекте - private void SetValue(string key, string value, dynamic jsonObj) + private void SetValue(string key, string value, dynamic jsonObj, bool publishData = true) { // Вызов базового метода Set для установки значения - base.Set(key, value); + if (publishData) base.Set(key, value); // Разделение ключа на части для навигации по структуре JSON var split = key.Split(':'); var context = jsonObj; @@ -37,6 +48,13 @@ private void SetValue(string key, string value, dynamic jsonObj) var currentKey = split[i]; if (i < split.Length - 1) // Если не последний элемент пути, обрабатываем вложенные объекты или массивы { + if (!publishData) + { + // Atomic mode must preserve siblings in the actual parent, not recreate + // nested containers by looking for their names at the document root. + context = GetOrCreateAtomicChild((JToken)context, currentKey, int.TryParse(split[i + 1], out _)); + continue; + } var child = jsonObj[currentKey]; if (child == null) // Если вложенный объект или массив не существует, создаем его { @@ -83,6 +101,11 @@ private dynamic GetJsonObj() // Переопределение метода Set для установки значения по ключу public override void Set(string key, string value) { + if (useAtomicWrites) + { + SetAtomically(json => SetValue(key, value, json, publishData: false)); + return; + } var jsonObj = GetJsonObj(); // Получаем текущий JSON-объект SetValue(key, value, jsonObj); // Устанавливаем значение Save(jsonObj); // Сохраняем изменения в файл @@ -91,6 +114,15 @@ public override void Set(string key, string value) // Перегрузка метода Set для установки значения любого типа public void Set(string key, object value) { + if (useAtomicWrites) + { + SetAtomically(json => + { + var token = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)) as JToken ?? new JValue(value); + WalkAndSet(key, token, json, publishData: false); + }); + return; + } var jsonObj = GetJsonObj(); // Получаем текущий JSON-объект var serialized = JsonConvert.SerializeObject(value); // Сериализуем значение var jToken = JsonConvert.DeserializeObject(serialized) as JToken ?? new JValue(value); // Преобразуем сериализованное значение в JToken @@ -99,7 +131,7 @@ public void Set(string key, object value) } // Рекурсивный метод для установки значения в JSON-объект - private void WalkAndSet(string key, JToken value, dynamic jsonObj) + private void WalkAndSet(string key, JToken value, dynamic jsonObj, bool publishData = true) { switch (value) { @@ -109,7 +141,7 @@ private void WalkAndSet(string key, JToken value, dynamic jsonObj) { var currentKey = $"{key}:{index}"; // Генерация ключа для элемента массива var elementValue = jArray[index]; // Получение элемента массива - WalkAndSet(currentKey, elementValue, jsonObj); // Рекурсивный вызов для установки значения элемента + WalkAndSet(currentKey, elementValue, jsonObj, publishData); // Рекурсивный вызов для установки значения элемента } break; } @@ -120,18 +152,116 @@ private void WalkAndSet(string key, JToken value, dynamic jsonObj) var propName = propertyInfo.Name; // Имя свойства var currentKey = key == null ? propName : $"{key}:{propName}"; // Генерация ключа для свойства var propValue = propertyInfo.Value; // Получение значения свойства - WalkAndSet(currentKey, propValue, jsonObj); // Рекурсивный вызов для установки значения свойства + WalkAndSet(currentKey, propValue, jsonObj, publishData); // Рекурсивный вызов для установки значения свойства } break; } case JValue jValue: // Обработка примитивного значения { - SetValue(key, jValue.ToString(), jsonObj); // Установка значения + SetValue(key, jValue.ToString(), jsonObj, publishData); // Установка значения break; } default: throw new ArgumentOutOfRangeException(nameof(value)); // Исключение для необработанных типов данных } } + + private static JToken GetOrCreateAtomicChild(JToken context, string key, bool nextIsArrayIndex) + { + if (context is JArray array) + { + if (!int.TryParse(key, out var index) || index < 0) + throw new ArgumentException("Configuration array path requires a non-negative index."); + if (index < array.Count) return array[index]; + JToken child = nextIsArrayIndex ? (JToken)new JArray() : new JObject(); + // Preserve the existing append/merge behavior rather than truncating array tails. + array.Add(child); + return child; + } + + var existing = context[key]; + if (existing != null) return existing; + JToken created = nextIsArrayIndex ? (JToken)new JArray() : new JObject(); + context[key] = created; + return created; + } + + private void SetAtomically(Action edit) + { + try { SetAtomicallyCore(edit); } + catch (Exception error) when (error is FormatException || error is JsonException) + { + // Parser/serializer exceptions can contain settings keys, values or getter errors. + throw new InvalidDataException("Configuration JSON could not be read or serialized; settings were not saved."); + } + } + + private void SetAtomicallyCore(Action edit) + { + var physicalPath = Source.FileProvider.GetFileInfo(Source.Path).PhysicalPath; + if (string.IsNullOrEmpty(physicalPath)) + throw new InvalidOperationException("Atomic configuration writes require a physical file path."); + var path = Path.GetFullPath(physicalPath); + lock (PathLocks.GetOrAdd(path, _ => new object())) + { + if (writesBlocked) + throw new IOException("Configuration writes are blocked after an unreconciled I/O failure. Restart the application or recreate the configuration root."); + + string original; + bool exists; + try { original = File.ReadAllText(path); exists = true; } + catch (FileNotFoundException) { original = "{}"; exists = false; } + + var originalData = ParseSnapshot(original); + var json = JObject.Parse(original); + var previous = json.DeepClone(); + edit(json); + if (exists && JToken.DeepEquals(previous, json)) + { + Data = originalData; + return; + } + var serialized = JsonConvert.SerializeObject(json, Formatting.Indented); + var candidateData = ParseSnapshot(serialized); + + var commitAttempted = false; + try + { + AtomicSettingsFile.Write(path, serialized, exists, (stage, file) => + { + if (stage == AtomicWriteStage.BeforeCommit) commitAttempted = true; + AtomicWriteCheckpoint?.Invoke(stage, file); + }); + Data = candidateData; + } + catch + { + if (commitAttempted) + { + // An ambiguous replace failure must not leave a fictitious in-memory snapshot. + try { Data = ParseSnapshot(File.ReadAllText(path)); } + catch { writesBlocked = true; } + } + throw; + } + } + } + + private static IDictionary ParseSnapshot(string json) + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json))) + return new SnapshotParser().Parse(stream); + } + + private sealed class SnapshotParser : JsonConfigurationProvider + { + internal SnapshotParser() : base(new JsonConfigurationSource()) { } + + internal IDictionary Parse(Stream stream) + { + Load(stream); + return Data; + } + } } } diff --git a/src/WritableJsonConfiguration/WritableJsonConfigurationSource.cs b/src/WritableJsonConfiguration/WritableJsonConfigurationSource.cs index a6914a1..903a9d7 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfigurationSource.cs +++ b/src/WritableJsonConfiguration/WritableJsonConfigurationSource.cs @@ -5,10 +5,16 @@ namespace WritableJsonConfiguration { public class WritableJsonConfigurationSource : JsonConfigurationSource { + /// + /// Use Windows atomic replacement and a previous-version .bak file. + /// Disabled by default to preserve existing consumers and platforms. + /// + public bool UseAtomicWrites { get; set; } + public override IConfigurationProvider Build(IConfigurationBuilder builder) { this.EnsureDefaults(builder); return (IConfigurationProvider)new WritableJsonConfigurationProvider(this); } } -} \ No newline at end of file +}