From 960224dd75b8407a2980e4492ef39385c9c9db73 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 21 Aug 2023 15:03:01 -0700 Subject: [PATCH] Consume IHostEnvironment.ContentRootFileProvider from CertificatePathWatcher We shouldn't be constructing our own `PhysicalFileProvider`s in case the user is abstracting away the file system (e.g. for testing). This simplifies a bunch of things, because there's no exactly one file provider, but we lose the ability to watch certificates outside the content root (which is probably more consistent with our design anyway). --- .../src/Internal/CertificatePathWatcher.cs | 101 +++-------- .../CertificatePathWatcherLoggerExtensions.cs | 6 + .../Core/test/CertificatePathWatcherTests.cs | 161 +++++++++--------- 3 files changed, 109 insertions(+), 159 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs index 7cb7234ac0a8..e3b8cce83ffa 100644 --- a/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs +++ b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; @@ -13,14 +12,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal; internal sealed partial class CertificatePathWatcher : IDisposable { - private readonly Func _fileProviderFactory; - private readonly string _contentRootDir; + private readonly IHostEnvironment _hostEnvironment; private readonly ILogger _logger; private readonly object _metadataLock = new(); - /// Acquire before accessing. - private readonly Dictionary _metadataForDirectory = new(); /// Acquire before accessing. private readonly Dictionary _metadataForFile = new(); @@ -28,21 +24,9 @@ internal sealed partial class CertificatePathWatcher : IDisposable private bool _disposed; public CertificatePathWatcher(IHostEnvironment hostEnvironment, ILogger logger) - : this( - hostEnvironment.ContentRootPath, - logger, - dir => Directory.Exists(dir) ? new PhysicalFileProvider(dir, ExclusionFilters.None) : null) - { - } - - /// - /// For testing. - /// - internal CertificatePathWatcher(string contentRootPath, ILogger logger, Func fileProviderFactory) { - _contentRootDir = contentRootPath; + _hostEnvironment = hostEnvironment; _logger = logger; - _fileProviderFactory = fileProviderFactory; } /// @@ -104,42 +88,32 @@ internal void AddWatchUnsynchronized(CertificateConfig certificateConfig) { Debug.Assert(certificateConfig.IsFileCert, "AddWatch called on non-file cert"); - var path = Path.Combine(_contentRootDir, certificateConfig.Path); - var dir = Path.GetDirectoryName(path)!; - - if (!_metadataForDirectory.TryGetValue(dir, out var dirMetadata)) - { - // If we wanted to detected deletions of this whole directory (which we don't since we ignore deletions), - // we'd probably need to watch the whole directory hierarchy - - var fileProvider = _fileProviderFactory(dir); - if (fileProvider is null) - { - _logger.DirectoryDoesNotExist(dir, path); - return; - } - - dirMetadata = new DirectoryWatchMetadata(fileProvider); - _metadataForDirectory.Add(dir, dirMetadata); - - _logger.CreatedDirectoryWatcher(dir); - } + var contentRootPath = _hostEnvironment.ContentRootPath; + var path = Path.Combine(contentRootPath, certificateConfig.Path); + var relativePath = Path.GetRelativePath(contentRootPath, path); if (!_metadataForFile.TryGetValue(path, out var fileMetadata)) { // PhysicalFileProvider appears to be able to tolerate non-existent files, as long as the directory exists var disposable = ChangeToken.OnChange( - () => dirMetadata.FileProvider.Watch(Path.GetFileName(path)), + () => + { + var changeToken = _hostEnvironment.ContentRootFileProvider.Watch(relativePath); + if (ReferenceEquals(changeToken, NullChangeToken.Singleton)) + { + _logger.NullChangeToken(path); + } + return changeToken; + }, static tuple => tuple.Item1.OnChange(tuple.Item2), ValueTuple.Create(this, path)); fileMetadata = new FileWatchMetadata(disposable); _metadataForFile.Add(path, fileMetadata); - dirMetadata.FileWatchCount++; // We actually don't care if the file doesn't exist - we'll watch in case it is created - fileMetadata.LastModifiedTime = GetLastModifiedTimeOrMinimum(path, dirMetadata.FileProvider); + fileMetadata.LastModifiedTime = GetLastModifiedTimeOrMinimum(path, _hostEnvironment.ContentRootFileProvider); _logger.CreatedFileWatcher(path); } @@ -153,7 +127,7 @@ internal void AddWatchUnsynchronized(CertificateConfig certificateConfig) _logger.AddedObserver(path); _logger.ObserverCount(path, fileMetadata.Configs.Count); - _logger.FileCount(dir, dirMetadata.FileWatchCount); + _logger.FileCount(contentRootPath, _metadataForFile.Count); } private DateTimeOffset GetLastModifiedTimeOrMinimum(string path, IFileProvider fileProvider) @@ -181,9 +155,6 @@ private void OnChange(string path) return; } - // Existence implied by the fact that we're tracking the file - var dirMetadata = _metadataForDirectory[Path.GetDirectoryName(path)!]; - // We ignore file changes that don't advance the last modified time. // For example, if we lose access to the network share the file is // stored on, we don't notify our listeners because no one wants @@ -192,7 +163,7 @@ private void OnChange(string path) // before a new cert is introduced with the old name. // This also helps us in scenarios where the underlying file system // reports more than one change for a single logical operation. - var lastModifiedTime = GetLastModifiedTimeOrMinimum(path, dirMetadata.FileProvider); + var lastModifiedTime = GetLastModifiedTimeOrMinimum(path, _hostEnvironment.ContentRootFileProvider); if (lastModifiedTime > fileMetadata.LastModifiedTime) { fileMetadata.LastModifiedTime = lastModifiedTime; @@ -219,6 +190,8 @@ private void OnChange(string path) { config.FileHasChanged = true; } + + _logger.FlaggedObservers(path, configs.Count); } // AddWatch and RemoveWatch don't affect the token, so this doesn't need to be under the semaphore. @@ -238,8 +211,8 @@ internal void RemoveWatchUnsynchronized(CertificateConfig certificateConfig) { Debug.Assert(certificateConfig.IsFileCert, "RemoveWatch called on non-file cert"); - var path = Path.Combine(_contentRootDir, certificateConfig.Path); - var dir = Path.GetDirectoryName(path)!; + var contentRootPath = _hostEnvironment.ContentRootPath; + var path = Path.Combine(contentRootPath, certificateConfig.Path); if (!_metadataForFile.TryGetValue(path, out var fileMetadata)) { @@ -257,35 +230,20 @@ internal void RemoveWatchUnsynchronized(CertificateConfig certificateConfig) _logger.RemovedObserver(path); - // If we found fileMetadata, there must be a containing/corresponding dirMetadata - var dirMetadata = _metadataForDirectory[dir]; - if (configs.Count == 0) { fileMetadata.Dispose(); _metadataForFile.Remove(path); - dirMetadata.FileWatchCount--; _logger.RemovedFileWatcher(path); - - if (dirMetadata.FileWatchCount == 0) - { - dirMetadata.Dispose(); - _metadataForDirectory.Remove(dir); - - _logger.RemovedDirectoryWatcher(dir); - } } _logger.ObserverCount(path, configs.Count); - _logger.FileCount(dir, dirMetadata.FileWatchCount); + _logger.FileCount(contentRootPath, _metadataForFile.Count); } /// Test hook - internal int TestGetDirectoryWatchCountUnsynchronized() => _metadataForDirectory.Count; - - /// Test hook - internal int TestGetFileWatchCountUnsynchronized(string dir) => _metadataForDirectory.TryGetValue(dir, out var metadata) ? metadata.FileWatchCount : 0; + internal int TestGetFileWatchCountUnsynchronized() => _metadataForFile.Count; /// Test hook internal int TestGetObserverCountUnsynchronized(string path) => _metadataForFile.TryGetValue(path, out var metadata) ? metadata.Configs.Count : 0; @@ -298,25 +256,12 @@ void IDisposable.Dispose() } _disposed = true; - foreach (var dirMetadata in _metadataForDirectory.Values) - { - dirMetadata.Dispose(); - } - foreach (var fileMetadata in _metadataForFile.Values) { fileMetadata.Dispose(); } } - private sealed class DirectoryWatchMetadata(IFileProvider fileProvider) : IDisposable - { - public readonly IFileProvider FileProvider = fileProvider; - public int FileWatchCount; - - public void Dispose() => (FileProvider as IDisposable)?.Dispose(); - } - private sealed class FileWatchMetadata(IDisposable disposable) : IDisposable { public readonly IDisposable Disposable = disposable; diff --git a/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs index d41543a342a6..9e6dce67bfd0 100644 --- a/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs +++ b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs @@ -7,6 +7,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal; internal static partial class CertificatePathWatcherLoggerExtensions { + [Obsolete("No longer fired")] [LoggerMessage(1, LogLevel.Warning, "Directory '{Directory}' does not exist so changes to the certificate '{Path}' will not be tracked.", EventName = "DirectoryDoesNotExist")] public static partial void DirectoryDoesNotExist(this ILogger logger, string directory, string path); @@ -16,12 +17,14 @@ internal static partial class CertificatePathWatcherLoggerExtensions [LoggerMessage(3, LogLevel.Warning, "Attempted to remove unknown observer from path '{Path}'.", EventName = "UnknownObserver")] public static partial void UnknownObserver(this ILogger logger, string path); + [Obsolete("No longer fired")] [LoggerMessage(4, LogLevel.Debug, "Created directory watcher for '{Directory}'.", EventName = "CreatedDirectoryWatcher")] public static partial void CreatedDirectoryWatcher(this ILogger logger, string directory); [LoggerMessage(5, LogLevel.Debug, "Created file watcher for '{Path}'.", EventName = "CreatedFileWatcher")] public static partial void CreatedFileWatcher(this ILogger logger, string path); + [Obsolete("No longer fired")] [LoggerMessage(6, LogLevel.Debug, "Removed directory watcher for '{Directory}'.", EventName = "RemovedDirectoryWatcher")] public static partial void RemovedDirectoryWatcher(this ILogger logger, string directory); @@ -60,4 +63,7 @@ internal static partial class CertificatePathWatcherLoggerExtensions [LoggerMessage(18, LogLevel.Trace, "Flagged {Count} observers of '{Path}' as changed.", EventName = "FlaggedObservers")] public static partial void FlaggedObservers(this ILogger logger, string path, int count); + + [LoggerMessage(18, LogLevel.Warning, "The host environment cannot watch path '{Path}' - is it within the content root?", EventName = "NullChangeToken")] + public static partial void NullChangeToken(this ILogger logger, string path); } diff --git a/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs b/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs index b4be5350f81c..cffea921c6e3 100644 --- a/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs +++ b/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Primitives; @@ -24,7 +25,7 @@ public void AddAndRemoveWatch(bool absoluteFilePath) var logger = LoggerFactory.CreateLogger(); - using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, NoChangeFileProvider.Instance), logger); var changeToken = watcher.GetChangeToken(); @@ -37,14 +38,10 @@ public void AddAndRemoveWatch(bool absoluteFilePath) watcher.AddWatchUnsynchronized(certificateConfig); - messageProps = GetLogMessageProperties(TestSink, "CreatedDirectoryWatcher"); - Assert.Equal(dir, messageProps["Directory"]); - messageProps = GetLogMessageProperties(TestSink, "CreatedFileWatcher"); Assert.Equal(filePath, messageProps["Path"]); - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); watcher.RemoveWatchUnsynchronized(certificateConfig); @@ -52,11 +49,7 @@ public void AddAndRemoveWatch(bool absoluteFilePath) messageProps = GetLogMessageProperties(TestSink, "RemovedFileWatcher"); Assert.Equal(filePath, messageProps["Path"]); - messageProps = GetLogMessageProperties(TestSink, "RemovedDirectoryWatcher"); - Assert.Equal(dir, messageProps["Directory"]); - - Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(filePath)); Assert.Same(changeToken, watcher.GetChangeToken()); @@ -79,7 +72,7 @@ public void WatchMultipleDirectories(int dirCount, int fileCount) dirs[i] = Path.Combine(rootDir, $"dir{i}"); } - using var watcher = new CertificatePathWatcher(rootDir, logger, _ => NoChangeFileProvider.Instance); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(rootDir, NoChangeFileProvider.Instance), logger); var certificateConfigs = new CertificateConfig[fileCount]; var filesInDir = new int[dirCount]; @@ -97,19 +90,14 @@ public void WatchMultipleDirectories(int dirCount, int fileCount) watcher.AddWatchUnsynchronized(certificateConfig); } - Assert.Equal(Math.Min(dirCount, fileCount), watcher.TestGetDirectoryWatchCountUnsynchronized()); - - for (int i = 0; i < dirCount; i++) - { - Assert.Equal(filesInDir[i], watcher.TestGetFileWatchCountUnsynchronized(dirs[i])); - } + Assert.Equal(fileCount, watcher.TestGetFileWatchCountUnsynchronized()); foreach (var certificateConfig in certificateConfigs) { watcher.RemoveWatchUnsynchronized(certificateConfig); } - Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized()); } [Theory] @@ -128,7 +116,7 @@ public async Task FileChanged(int observerCount) var fileLastModifiedTime = DateTimeOffset.UtcNow; fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime); - using var watcher = new CertificatePathWatcher(dir, logger, _ => fileProvider); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, fileProvider), logger); var signalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -146,8 +134,7 @@ public async Task FileChanged(int observerCount) watcher.AddWatchUnsynchronized(certificateConfigs[i]); } - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(observerCount, watcher.TestGetObserverCountUnsynchronized(filePath)); // Simulate file change on disk @@ -178,7 +165,7 @@ public async Task OutOfOrderLastModifiedTime() var fileLastModifiedTime = DateTimeOffset.UtcNow; fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime); - using var watcher = new CertificatePathWatcher(dir, logger, _ => fileProvider); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, fileProvider), logger); var certificateConfig = new CertificateConfig { @@ -199,8 +186,7 @@ public async Task OutOfOrderLastModifiedTime() var oldChangeToken = watcher.GetChangeToken(); - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); // Simulate file change on disk @@ -212,31 +198,6 @@ public async Task OutOfOrderLastModifiedTime() Assert.False(oldChangeToken.HasChanged); } - [Fact] - public void DirectoryDoesNotExist() - { - var dir = Path.Combine(Directory.GetCurrentDirectory(), Path.GetRandomFileName()); - - Assert.False(Directory.Exists(dir)); - - var logger = LoggerFactory.CreateLogger(); - - // Returning null indicates that the directory does not exist - using var watcher = new CertificatePathWatcher(dir, logger, _ => null); - - var certificateConfig = new CertificateConfig - { - Path = Path.Combine(dir, "test.pfx"), - }; - - watcher.AddWatchUnsynchronized(certificateConfig); - - var messageProps = GetLogMessageProperties(TestSink, "DirectoryDoesNotExist"); - Assert.Equal(dir, messageProps["Directory"]); - - Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); - } - [Theory] [InlineData(true)] [InlineData(false)] @@ -248,7 +209,7 @@ public void RemoveUnknownFileWatch(bool previouslyAdded) var logger = LoggerFactory.CreateLogger(); - using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, NoChangeFileProvider.Instance), logger); var certificateConfig = new CertificateConfig { @@ -282,7 +243,7 @@ public void RemoveUnknownFileObserver(bool previouslyAdded) var logger = LoggerFactory.CreateLogger(); - using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, NoChangeFileProvider.Instance), logger); var certificateConfig1 = new CertificateConfig { @@ -322,7 +283,7 @@ public void ReuseFileObserver() var logger = LoggerFactory.CreateLogger(); - using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, NoChangeFileProvider.Instance), logger); var certificateConfig = new CertificateConfig { @@ -359,7 +320,7 @@ public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNew var fileLastModifiedTime = DateTimeOffset.UtcNow; fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime); - using var watcher = new CertificatePathWatcher(dir, logger, _ => fileProvider); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, fileProvider), logger); var certificateConfig = new CertificateConfig { @@ -368,8 +329,7 @@ public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNew watcher.AddWatchUnsynchronized(certificateConfig); - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); var changeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -402,8 +362,7 @@ public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNew await logNoLastModifiedTcs.Task.DefaultTimeout(); } - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); Assert.False(changeTcs.Task.IsCompleted); @@ -433,7 +392,7 @@ public void UpdateWatches() var logger = LoggerFactory.CreateLogger(); - using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(dir, NoChangeFileProvider.Instance), logger); var changeToken = watcher.GetChangeToken(); @@ -455,22 +414,19 @@ public void UpdateWatches() // Add certificateConfig1 watcher.UpdateWatches(new List { }, new List { certificateConfig1 }); - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); // Remove certificateConfig1 watcher.UpdateWatches(new List { certificateConfig1 }, new List { }); - Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(filePath)); // Re-add certificateConfig1 watcher.UpdateWatches(new List { }, new List { certificateConfig1 }); - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); watcher.UpdateWatches( @@ -491,11 +447,56 @@ public void UpdateWatches() certificateConfig3, // Add it again }); - Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); - Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); Assert.Equal(3, watcher.TestGetObserverCountUnsynchronized(filePath)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PathOutsideContentRoot(bool absoluteFilePath) + { + var testRootDir = Directory.GetCurrentDirectory(); + var contentRootDir = Path.Combine(testRootDir, Path.GetRandomFileName()); + var outsideContentRootDir = Path.Combine(testRootDir, Path.GetRandomFileName()); + var outsideContentRootFile = Path.Combine(outsideContentRootDir, Path.GetRandomFileName()); + var outsideContentRootFileRelativePath = Path.GetRelativePath(contentRootDir, outsideContentRootFile); + var loggedPath = absoluteFilePath ? outsideContentRootFile : Path.Combine(contentRootDir, outsideContentRootFileRelativePath); + + var logger = LoggerFactory.CreateLogger(); + + using var watcher = new CertificatePathWatcher(new MockHostEnvironment(contentRootDir, NoChangeFileProvider.Instance), logger); + + var changeToken = watcher.GetChangeToken(); + + var certificateConfig = new CertificateConfig + { + Path = absoluteFilePath ? outsideContentRootFile : outsideContentRootFileRelativePath, + }; + + IDictionary messageProps; + + watcher.AddWatchUnsynchronized(certificateConfig); + + messageProps = GetLogMessageProperties(TestSink, "CreatedFileWatcher"); + Assert.Equal(loggedPath, messageProps["Path"]); + + messageProps = GetLogMessageProperties(TestSink, "NullChangeToken"); + Assert.Equal(loggedPath, messageProps["Path"]); + + // Ideally, these would be zero, but having a dummy change token isn't the end of the world + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(loggedPath)); + + watcher.RemoveWatchUnsynchronized(certificateConfig); + + messageProps = GetLogMessageProperties(TestSink, "RemovedFileWatcher"); + Assert.Equal(loggedPath, messageProps["Path"]); + + Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized()); + Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(loggedPath)); + } + private static IDictionary GetLogMessageProperties(ITestSink testSink, string eventName) { var writeContext = Assert.Single(testSink.Writes.Where(wc => wc.EventId.Name == eventName)); @@ -514,20 +515,7 @@ private NoChangeFileProvider() IDirectoryContents IFileProvider.GetDirectoryContents(string subpath) => throw new NotSupportedException(); IFileInfo IFileProvider.GetFileInfo(string subpath) => throw new NotSupportedException(); - IChangeToken IFileProvider.Watch(string filter) => NoChangeChangeToken.Instance; - - private sealed class NoChangeChangeToken : IChangeToken - { - public static readonly IChangeToken Instance = new NoChangeChangeToken(); - - private NoChangeChangeToken() - { - } - - bool IChangeToken.HasChanged => false; - bool IChangeToken.ActiveChangeCallbacks => true; - IDisposable IChangeToken.RegisterChangeCallback(Action callback, object state) => DummyDisposable.Instance; - } + IChangeToken IFileProvider.Watch(string filter) => NullChangeToken.Singleton; } private sealed class DummyDisposable : IDisposable @@ -599,4 +587,15 @@ public MockFileInfo(DateTimeOffset? lastModifiedTime) Stream IFileInfo.CreateReadStream() => throw new NotSupportedException(); } } + + private sealed class MockHostEnvironment(string contentRootPath, IFileProvider contentRootFileProvider) : IHostEnvironment + { + private readonly string _contentRootPath = contentRootPath; + private readonly IFileProvider _contentRootFileProvider = contentRootFileProvider; + + string IHostEnvironment.EnvironmentName { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + string IHostEnvironment.ApplicationName { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + string IHostEnvironment.ContentRootPath { get => _contentRootPath; set => throw new NotImplementedException(); } + IFileProvider IHostEnvironment.ContentRootFileProvider { get => _contentRootFileProvider; set => throw new NotImplementedException(); } + } }