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
54 changes: 46 additions & 8 deletions src/NodePilot.Api/Configuration/RuntimeOverridesWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration.Json;
using NodePilot.Core.Interfaces;

namespace NodePilot.Api.Configuration;
Expand Down Expand Up @@ -127,11 +128,11 @@ private static bool ReencryptValues(JsonNode node, ISecretProtector protector, C

internal static void ReplaceWithoutBackup(string path, JsonObject root)
{
var content = SerializeAndValidate(root);
var temporary = path + ".migration." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllText(temporary, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
File.WriteAllBytes(temporary, content);
try { File.Replace(temporary, path, destinationBackupFileName: null); }
catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or UnauthorizedAccessException)
{
Expand All @@ -147,13 +148,40 @@ internal static void ReplaceWithoutBackup(string path, JsonObject root)
/// </summary>
public JsonObject ReadOrEmpty()
{
if (!File.Exists(_path)) return new JsonObject();
var nodeOptions = new JsonNodeOptions { PropertyNameCaseInsensitive = true };
if (!File.Exists(_path)) return new JsonObject(nodeOptions);
var raw = File.ReadAllText(_path);
if (string.IsNullOrWhiteSpace(raw)) return new JsonObject();
var parsed = JsonNode.Parse(raw) as JsonObject
if (string.IsNullOrWhiteSpace(raw)) return new JsonObject(nodeOptions);
var parsed = JsonNode.Parse(raw, documentOptions:
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }) as JsonObject
?? throw new InvalidOperationException(
$"Runtime overrides file '{_path}' must contain a JSON object at the root.");
return parsed;
return NormalizeConfigurationKeys(parsed).AsObject();
}

private static JsonNode NormalizeConfigurationKeys(JsonNode node)
{
if (node is JsonObject source)
{
var result = new JsonObject(new JsonNodeOptions { PropertyNameCaseInsensitive = true });
MergeConfigurationObject(result, source);
return result;
}
if (node is JsonArray array)
return new JsonArray(array.Select(child => child is null ? null : NormalizeConfigurationKeys(child)).ToArray());
return node.DeepClone();
}

private static void MergeConfigurationObject(JsonObject target, JsonObject source)
{
foreach (var (key, value) in source)
{
// IConfiguration permits case-variant sections when their leaf keys are disjoint.
if (target[key] is JsonObject existing && value is JsonObject nested)
MergeConfigurationObject(existing, nested);
else
target.Add(key, value is null ? null : NormalizeConfigurationKeys(value));
}
}

/// <summary>
Expand Down Expand Up @@ -463,9 +491,9 @@ private void EnsureDirectoryExists()

private void WriteAtomic(JsonObject root)
{
var content = SerializeAndValidate(root);
var tmp = _path + ".tmp";
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(tmp, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
File.WriteAllBytes(tmp, content);

if (!File.Exists(_path))
{
Expand Down Expand Up @@ -497,6 +525,16 @@ private void WriteAtomic(JsonObject root)
RotateBackups();
}

private static byte[] SerializeAndValidate(JsonObject root)
{
var content = Encoding.UTF8.GetBytes(root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
// The encrypting production provider uses this parser before decrypting leaf values.
using var provider = new JsonConfigurationProvider(new JsonConfigurationSource());
using var stream = new MemoryStream(content, writable: false);
provider.Load(stream);
return content;
}

private void RotateBackups()
{
var dir = Path.GetDirectoryName(_path);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NodePilot.Api.Configuration;
Expand Down Expand Up @@ -33,6 +34,88 @@ private RuntimeOverridesWriter NewWriter(string? subPath = null)
return new RuntimeOverridesWriter(path, NullLogger<RuntimeOverridesWriter>.Instance);
}

[Fact]
public void TryUpdateSectionAtomic_LowerCaseSection_RemainsLoadable()
{
var writer = NewWriter();
File.WriteAllText(writer.OverridesPath, """{"stats":{"RefreshIntervalMinutes":5,"WindowDays":7}}""");
var result = writer.TryUpdateSectionAtomic("Stats", writer.ComputeSectionEtag("Stats"),
new JsonObject { ["RefreshIntervalMinutes"] = 10, ["WindowDays"] = 7 },
null, "admin", DateTimeOffset.UtcNow);

result.Success.Should().BeTrue();
var config = new ConfigurationBuilder().AddJsonFile(writer.OverridesPath).Build();
using var disposable = (IDisposable)config;
config["Stats:RefreshIntervalMinutes"].Should().Be("10");
config["Stats:WindowDays"].Should().Be("7");
writer.ReadOrEmpty().Count(pair => pair.Key.Equals("Stats", StringComparison.OrdinalIgnoreCase)).Should().Be(1);
}

[Fact]
public void TryUpdateSectionAtomic_MixedCaseNestedPath_PreservesSiblingAndReadsExistingValue()
{
var writer = NewWriter();
File.WriteAllText(writer.OverridesPath,
"""{"retention":{"executions":{"MaxAgeDays":7},"auditlog":{"MaxAgeDays":90}}}""");
(writer.ReadSection("Retention:Executions")?["MaxAgeDays"]?.GetValue<int>()).Should().Be(7);

var result = writer.TryUpdateSectionAtomic("Retention:Executions", writer.ComputeSectionEtag("Retention:Executions"),
new JsonObject { ["MaxAgeDays"] = 14 }, null, "admin", DateTimeOffset.UtcNow);

result.Success.Should().BeTrue();
var config = new ConfigurationBuilder().AddJsonFile(writer.OverridesPath).Build();
using var disposable = (IDisposable)config;
config["Retention:Executions:MaxAgeDays"].Should().Be("14");
config["Retention:AuditLog:MaxAgeDays"].Should().Be("90");
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public void Write_DuplicateFlattenedKeys_RejectsCandidateAndPreservesOriginal(bool replace)
{
var writer = NewWriter();
const string original = """{"Stats":{"WindowDays":7}}""";
File.WriteAllText(writer.OverridesPath, original);
var invalid = new JsonObject
{
["Stats"] = new JsonObject { ["WindowDays"] = 14 },
["stats:windowdays"] = 30
};

var act = () =>
{
if (replace) writer.ReplaceAll(invalid);
else writer.MutateAndWrite(root => root["stats:windowdays"] = 30);
};

act.Should().Throw<FormatException>();
File.ReadAllText(writer.OverridesPath).Should().Be(original);
Directory.GetFiles(_tempDir, "*.bak.*").Should().BeEmpty();
Directory.GetFiles(_tempDir, "*.tmp").Should().BeEmpty();
}

[Fact]
public void TryUpdateSectionAtomic_DisjointCaseVariantSections_MergesTheirExistingValues()
{
var writer = NewWriter();
File.WriteAllText(writer.OverridesPath,
"""{"retention":{"executions":{"MaxAgeDays":7}},"Retention":{"auditlog":{"MaxAgeDays":90}}}""");
var before = new ConfigurationBuilder().AddJsonFile(writer.OverridesPath).Build();
using var beforeDisposable = (IDisposable)before;
before["Retention:Executions:MaxAgeDays"].Should().Be("7");
before["Retention:AuditLog:MaxAgeDays"].Should().Be("90");

var result = writer.TryUpdateSectionAtomic("Retention:Executions", writer.ComputeSectionEtag("Retention:Executions"),
new JsonObject { ["MaxAgeDays"] = 14 }, null, "admin", DateTimeOffset.UtcNow);

result.Success.Should().BeTrue();
var after = new ConfigurationBuilder().AddJsonFile(writer.OverridesPath).Build();
using var afterDisposable = (IDisposable)after;
after["Retention:Executions:MaxAgeDays"].Should().Be("14");
after["Retention:AuditLog:MaxAgeDays"].Should().Be("90");
}

[Fact]
public void ReadOrEmpty_FileMissing_ReturnsEmptyObject()
{
Expand Down
Loading