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(); } + } }