From b70e9ae1300bf9ff8761bf01e9c12dc829cb80d1 Mon Sep 17 00:00:00 2001 From: Kibnet Philosoff Date: Mon, 21 Sep 2026 16:11:00 +0300 Subject: [PATCH 1/3] fix(settings): normalize file-only ACL flags --- .github/workflows/dotnetcore.yml | 14 ++++++++++++++ .../AtomicWriteTests.cs | 2 ++ .../AtomicSettingsFile.cs | 12 ++++++++---- .../WritableJsonConfiguration.csproj | 2 +- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 1b46bd9..ae58661 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -38,3 +38,17 @@ jobs: - name: Push nuget packages to GitHub registry if: github.event_name == 'push' run: dotnet nuget push ./out/*.nupkg --skip-duplicate --no-symbols -s "github" + + windows-tests: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore + run: dotnet restore + - name: Test Windows atomic writes + run: dotnet test --configuration Release --no-restore diff --git a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs index 81f75de..84d275f 100644 --- a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs +++ b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs @@ -369,6 +369,8 @@ 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.True(AtomicSettingsFile.HaveEquivalentAccess( + Security("D:P(A;OICINP;FR;;;SY)"), Security("D:P(A;;FR;;;SY)"))); 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)"))); diff --git a/src/WritableJsonConfiguration/AtomicSettingsFile.cs b/src/WritableJsonConfiguration/AtomicSettingsFile.cs index 779466a..b4dd75b 100644 --- a/src/WritableJsonConfiguration/AtomicSettingsFile.cs +++ b/src/WritableJsonConfiguration/AtomicSettingsFile.cs @@ -77,9 +77,11 @@ private static string AccessFingerprint(FileSecurity security) 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. + // Windows removes inherited provenance and can normalize propagation flags when + // persisting an ACL on a file. Those flags only control inheritance by children; + // a file cannot have children. InheritOnly still changes access to this file and + // must remain significant. 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); @@ -92,7 +94,9 @@ private static string AccessFingerprint(FileSecurity security) var bytes = new byte[ace.BinaryLength]; ace.GetBinaryForm(bytes, 0); var normalized = GenericAce.CreateFromBinaryForm(bytes, 0); - if (canReorder) normalized.AceFlags &= ~AceFlags.Inherited; + if (canReorder) + normalized.AceFlags &= ~(AceFlags.Inherited | AceFlags.ObjectInherit | + AceFlags.ContainerInherit | AceFlags.NoPropagateInherit); normalized.GetBinaryForm(bytes, 0); group.Add(Convert.ToBase64String(bytes)); } diff --git a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj index 47ec4bd..934de4f 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj +++ b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj @@ -5,7 +5,7 @@ Kibnet https://github.com/Kibnet/WritableJsonConfiguration https://github.com/Kibnet/WritableJsonConfiguration - 8.1.1 + 8.1.2 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 From 94a8946efdd13a05944bb7f9a3595dc2d8dbdcf6 Mon Sep 17 00:00:00 2001 From: Kibnet Philosoff Date: Mon, 21 Sep 2026 16:13:43 +0300 Subject: [PATCH 2/3] test(settings): expose post-replace ACL mismatch --- WritableJsonConfiguration.Tests/AtomicWriteTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs index 84d275f..867a539 100644 --- a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs +++ b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs @@ -357,6 +357,11 @@ public void PersistedProtectedBootstrapBackupAllowsSuccessiveSavesOfInheritedMai using var root = (IDisposable)Create(); var configuration = (IConfigurationRoot)root; configuration["Theme"] = "first"; + var mainPermissions = new FileInfo(SettingsPath).GetAccessControl(AccessControlSections.Access); + var backupPermissions = new FileInfo(SettingsPath + ".bak").GetAccessControl(AccessControlSections.Access); + Assert.True(AtomicSettingsFile.HaveEquivalentAccess(mainPermissions, backupPermissions), + $"Main ACL: {mainPermissions.GetSecurityDescriptorSddlForm(AccessControlSections.Access)}; " + + $"backup ACL: {backupPermissions.GetSecurityDescriptorSddlForm(AccessControlSections.Access)}"); configuration["Theme"] = "second"; configuration["Theme"] = "third"; Assert.Equal("third", configuration["Theme"]); From 307f76e1321a465fae149cc73997936e566cc9e5 Mon Sep 17 00:00:00 2001 From: Kibnet Philosoff Date: Mon, 21 Sep 2026 16:16:44 +0300 Subject: [PATCH 3/3] fix(settings): deduplicate equivalent file ACEs --- .../AtomicWriteTests.cs | 2 ++ .../AtomicSettingsFile.cs | 15 ++++++++++++++- .../WritableJsonConfiguration.csproj | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs index 867a539..48322a9 100644 --- a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs +++ b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs @@ -376,6 +376,8 @@ public void AccessComparisonIgnoresOnlyProvenanceAndOrderWithinSameKind() Assert.True(AtomicSettingsFile.HaveEquivalentAccess(inherited, explicitReversed)); Assert.True(AtomicSettingsFile.HaveEquivalentAccess( Security("D:P(A;OICINP;FR;;;SY)"), Security("D:P(A;;FR;;;SY)"))); + Assert.True(AtomicSettingsFile.HaveEquivalentAccess( + Security("D:P(A;;FR;;;SY)(A;ID;FR;;;SY)"), Security("D:P(A;ID;FR;;;SY)"))); 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)"))); diff --git a/src/WritableJsonConfiguration/AtomicSettingsFile.cs b/src/WritableJsonConfiguration/AtomicSettingsFile.cs index b4dd75b..3e7f513 100644 --- a/src/WritableJsonConfiguration/AtomicSettingsFile.cs +++ b/src/WritableJsonConfiguration/AtomicSettingsFile.cs @@ -108,7 +108,20 @@ private static void AppendGroup(StringBuilder result, List group, int ty { if (group.Count == 0) return; group.Sort(StringComparer.Ordinal); - result.Append(type).Append(':').Append(string.Join(",", group)).Append(';'); + result.Append(type).Append(':'); + string previous = null; + bool appended = false; + foreach (var entry in group) + { + // File.Replace can retain the same effective ACE once as explicit and once as + // inherited. After provenance normalization, exact duplicates do not change access. + if (entry == previous) continue; + if (appended) result.Append(','); + result.Append(entry); + previous = entry; + appended = true; + } + result.Append(';'); group.Clear(); } } diff --git a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj index 934de4f..b7ab6f6 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj +++ b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj @@ -10,7 +10,7 @@ JSON_logo.png README.md MIT - Fix atomic-path handling for numeric object keys and reject sparse array indexes instead of writing a different index. + Fix false atomic-write ACL mismatches after Windows normalizes equivalent file access entries during File.Replace.