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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions WritableJsonConfiguration.Tests/AtomicWriteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>());
}

[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<ArgumentException>(() =>
{
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<ArgumentException>(() => 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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
<Authors>Kibnet</Authors>
<RepositoryUrl>https://github.com/Kibnet/WritableJsonConfiguration</RepositoryUrl>
<PackageProjectUrl>https://github.com/Kibnet/WritableJsonConfiguration</PackageProjectUrl>
<PackageVersion>8.1.0</PackageVersion>
<PackageVersion>8.1.1</PackageVersion>
<Description>Source of configurations in JSON format with the ability to edit values directly from the running application. Based on Microsoft.Extensions.Configuration.</Description>
<PackageIcon>JSON_logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReleaseNotes>Add opt-in atomic Windows writes with durable replacement, one backup, serialized in-process updates, and fail-closed permission handling.</PackageReleaseNotes>
<PackageReleaseNotes>Fix atomic-path handling for numeric object keys and reject sparse array indexes instead of writing a different index.</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup>
Expand Down
37 changes: 35 additions & 2 deletions src/WritableJsonConfiguration/WritableJsonConfigurationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -172,20 +177,48 @@ 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);
return child;
}

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<JObject> edit)
{
try { SetAtomicallyCore(edit); }
Expand Down
Loading