diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1824a..44cde15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented in this file. +## 8.1.1 - 2026-09-21 + +### Fixed + +- Treat numeric path segments as property names when their current JSON container is an object. +- Reject sparse array indexes in atomic mode instead of silently appending at a different index. +- Reject paths that attempt to continue through an existing scalar value. + ## 8.1.0 - 2026-09-21 ### Added diff --git a/README.md b/README.md index b1a6ec9..cd7cd98 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ the main file. No-op saves do not rotate the backup. Memory is published after t 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. +Numeric path segments remain property names inside JSON objects. Inside arrays they +are indexes; sparse indexes are rejected rather than redirected to another element. Temporary files receive the source file's restricted Windows access permissions before any settings bytes are written. Existing main/backup permission differences diff --git a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs index 6927b28..81f75de 100644 --- a/WritableJsonConfiguration.Tests/AtomicWriteTests.cs +++ b/WritableJsonConfiguration.Tests/AtomicWriteTests.cs @@ -106,6 +106,63 @@ public void AtomicNestedArrayUpdatesPreserveOtherFieldsAndTail(bool objectOverlo Assert.Equal("kept", ((IConfigurationRoot)reloaded)["A:B:Items:1:Tail"]); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AtomicNumericObjectKeysRemainObjectProperties(bool objectOverload) + { + File.WriteAllText(SettingsPath, "{\"Years\":{\"2024\":\"old\",\"Label\":\"kept\"}}"); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + if (objectOverload) configuration.Set("Years:2024", (object)"new"); + else configuration["Years:2024"] = "new"; + + Assert.Equal("new", configuration["Years:2024"]); + Assert.Equal("kept", configuration["Years:Label"]); + var disk = JObject.Parse(File.ReadAllText(SettingsPath)); + Assert.Equal(JTokenType.Object, disk["Years"]!.Type); + Assert.Equal("new", disk["Years"]!["2024"]!.Value()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AtomicSparseArrayIndexesAreRejectedWithoutWritingAnotherIndex(bool objectOverload) + { + const string original = "{\"Items\":[],\"Outside\":\"kept\"}"; + File.WriteAllText(SettingsPath, original); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + + Assert.Throws(() => + { + if (objectOverload) configuration.Set("Items:2", new { Name = "new" }); + else configuration["Items:2:Name"] = "new"; + }); + + Assert.Null(configuration["Items:0:Name"]); + Assert.Null(configuration["Items:2:Name"]); + Assert.Equal("kept", configuration["Outside"]); + Assert.True(JToken.DeepEquals(JObject.Parse(original), JObject.Parse(File.ReadAllText(SettingsPath)))); + Assert.False(File.Exists(SettingsPath + ".bak")); + } + + [Fact] + public void AtomicPathsCannotContinueThroughExistingScalarValues() + { + const string original = "{\"Theme\":\"old\"}"; + File.WriteAllText(SettingsPath, original); + using var root = (IDisposable)Create(); + var configuration = (IConfigurationRoot)root; + + Assert.Throws(() => configuration["Theme:Nested"] = "new"); + + Assert.Equal("old", configuration["Theme"]); + Assert.Null(configuration["Theme:Nested"]); + Assert.Equal(original, File.ReadAllText(SettingsPath)); + Assert.False(File.Exists(SettingsPath + ".bak")); + } + [Fact] public void DefaultRemainsLegacyAndDoesNotCreateBackup() { diff --git a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj index 497618d..47ec4bd 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj +++ b/src/WritableJsonConfiguration/WritableJsonConfiguration.csproj @@ -5,12 +5,12 @@ Kibnet https://github.com/Kibnet/WritableJsonConfiguration https://github.com/Kibnet/WritableJsonConfiguration - 8.1.0 + 8.1.1 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. + Fix atomic-path handling for numeric object keys and reject sparse array indexes instead of writing a different index. diff --git a/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs b/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs index cb2eb96..50cec47 100644 --- a/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs +++ b/src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs @@ -71,6 +71,11 @@ private void SetValue(string key, string value, dynamic jsonObj, bool publishDat } else // Если последний элемент пути, устанавливаем значение { + if (!publishData) + { + SetAtomicLeaf((JToken)context, currentKey, value); + continue; + } if (int.TryParse(currentKey, out var index)) // Обработка индекса массива { if (context is JArray array) @@ -172,7 +177,9 @@ private static JToken GetOrCreateAtomicChild(JToken context, string key, bool ne { 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]; + if (index < array.Count) return RequireAtomicContainer(array[index]); + if (index > array.Count) + throw new ArgumentException("Sparse configuration array indexes are not supported."); JToken child = nextIsArrayIndex ? (JToken)new JArray() : new JObject(); // Preserve the existing append/merge behavior rather than truncating array tails. array.Add(child); @@ -180,12 +187,38 @@ private static JToken GetOrCreateAtomicChild(JToken context, string key, bool ne } var existing = context[key]; - if (existing != null) return existing; + if (existing != null) return RequireAtomicContainer(existing); JToken created = nextIsArrayIndex ? (JToken)new JArray() : new JObject(); context[key] = created; return created; } + private static JToken RequireAtomicContainer(JToken token) + { + if (token is JObject || token is JArray) return token; + throw new ArgumentException("Configuration path cannot continue through a scalar value."); + } + + private static void SetAtomicLeaf(JToken context, string key, string value) + { + if (context is JObject objectContext) + { + // Numeric property names are valid object keys; only arrays interpret them as indexes. + objectContext[key] = value; + return; + } + + if (context is JArray arrayContext && int.TryParse(key, out var index) && index >= 0) + { + if (index < arrayContext.Count) arrayContext[index] = value; + else if (index == arrayContext.Count) arrayContext.Add(value); + else throw new ArgumentException("Sparse configuration array indexes are not supported."); + return; + } + + throw new ArgumentException("Configuration path does not match its JSON container."); + } + private void SetAtomically(Action edit) { try { SetAtomicallyCore(edit); }