From 4e8d3b88657256354fcbddd38394e0f9f6377f37 Mon Sep 17 00:00:00 2001
From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com>
Date: Thu, 2 Apr 2026 19:55:48 -0500
Subject: [PATCH] feat: allow secure storage provider to raise event when
storage changes and implement physical file provider in json storage provider
to detect when changes occur
---
.../Storage/ISecureStorageProvider.cs | 7 +-
.../Storage/JsonFileStorageProvider.cs | 86 ++++++++++++++-----
2 files changed, 70 insertions(+), 23 deletions(-)
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs
index 8b5e0db..9c45878 100644
--- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/ISecureStorageProvider.cs
@@ -3,8 +3,13 @@ namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
///
/// Defines a contract for a provider that stores and retrieves secure configuration data.
///
-public interface ISecureStorageProvider
+public interface ISecureStorageProvider : IDisposable
{
+ ///
+ ///
+ ///
+ event EventHandler StorageChanged;
+
///
/// Reads the value associated with the specified key asynchronously.
///
diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs
index 309abb2..4884c3a 100644
--- a/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs
+++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Storage/JsonFileStorageProvider.cs
@@ -1,16 +1,52 @@
using System.Text.Json;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
+
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
///
/// Provides a mechanism to store and retrieve secure configuration data in a JSON file.
///
-/// The configuring the storage provider, including file paths.
-public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecureStorageProvider
+public sealed class JsonFileStorageProvider : ISecureStorageProvider
{
private static readonly SemaphoreSlim FileLock = new(1, 1);
- private readonly JsonStorageOptions _options = options
- ?? throw new ArgumentNullException(nameof(options));
+ private readonly PhysicalFileProvider? _fileProvider;
+ private readonly IDisposable? _changeTokenRegistration;
+ private readonly JsonStorageOptions _options;
+
+ ///
+ public event EventHandler? StorageChanged;
+
+ ///
+ /// Provides a mechanism to store and retrieve secure configuration data in a JSON file.
+ ///
+ /// The configuring the storage provider, including file paths.
+ public JsonFileStorageProvider(JsonStorageOptions options)
+ {
+ _options = options
+ ?? throw new ArgumentNullException(nameof(options));
+
+ var directory = Path.GetDirectoryName(_options.FullPath);
+
+ if (string.IsNullOrWhiteSpace(directory) is false && Directory.Exists(directory))
+ {
+ _fileProvider = new PhysicalFileProvider(directory)
+ {
+ UseActivePolling = true,
+ UsePollingFileWatcher = true,
+ };
+
+ _changeTokenRegistration = ChangeToken.OnChange(
+ () => _fileProvider.Watch(_options.FileName),
+ () =>
+ {
+ Thread.Sleep(250);
+ StorageChanged?.Invoke(this, EventArgs.Empty);
+ }
+ );
+ }
+ }
///
/// Reads the value associated with the specified key from the JSON file asynchronously.
@@ -87,10 +123,14 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur
}
///
- /// Acquires an exclusive lock and loads the configuration data from the JSON file.
+ ///
///
- /// A cancellation token to observe while waiting for the lock or during the load operation.
- /// A dictionary containing the loaded configuration data.
+ public void Dispose()
+ {
+ _changeTokenRegistration?.Dispose();
+ _fileProvider?.Dispose();
+ }
+
private async Task> AcquireLockAndLoadAsync(CancellationToken ct)
{
await FileLock.WaitAsync(ct).ConfigureAwait(false);
@@ -105,11 +145,6 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur
}
}
- ///
- /// Loads the configuration data from the JSON file.
- ///
- /// A cancellation token to observe while loading the data.
- /// A dictionary containing the loaded configuration data, or an empty dictionary if the file does not exist or is empty.
private async Task> LoadAsync(CancellationToken ct)
{
if (File.Exists(_options.FullPath) is false)
@@ -117,7 +152,16 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur
return [];
}
- using var stream = new FileStream(_options.FullPath, FileMode.Open, FileAccess.Read);
+ var stream = new FileStream(
+ _options.FullPath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite,
+ bufferSize: 4096,
+ FileOptions.Asynchronous | FileOptions.SequentialScan
+ );
+
+ await using var _ = stream.ConfigureAwait(false);
if (stream.Length is 0)
{
@@ -130,24 +174,22 @@ public sealed class JsonFileStorageProvider(JsonStorageOptions options) : ISecur
return data ?? [];
}
- ///
- /// Saves the configuration data to the JSON file.
- ///
- /// The configuration data to save.
- /// A cancellation token to observe while saving the data.
- /// A task that represents the asynchronous save operation.
private async Task SaveAsync(Dictionary data, CancellationToken ct)
{
Directory.CreateDirectory(_options.DirectoryPath);
- using var stream = new FileStream(
+ var stream = new FileStream(
_options.FullPath,
FileMode.Create,
FileAccess.Write,
- FileShare.None
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.Asynchronous
);
+ await using var _ = stream.ConfigureAwait(false);
+
await JsonSerializer.SerializeAsync(stream, data, SecureConfigJsonContext.Default.DictionaryStringString, ct)
.ConfigureAwait(false);
}
-}
+}
\ No newline at end of file