22// The .NET Foundation licenses this file to you under the MIT license.
33
44using System ;
5+ using System . IO ;
56using System . Runtime . Versioning ;
7+ using System . Security ;
68using System . Threading ;
79using System . Threading . Tasks ;
10+ using Microsoft . Extensions . Configuration ;
11+ using Microsoft . Extensions . FileProviders ;
812using Microsoft . Extensions . Logging ;
913using Microsoft . Extensions . Logging . Abstractions ;
1014using Microsoft . Extensions . Options ;
@@ -44,6 +48,13 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
4448 /// <param name="loggerFactory">An object to configure the logging system and create instances of <see cref="ILogger"/> from the registered <see cref="ILoggerProvider"/>.</param>
4549 /// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="environment"/> or <paramref name="applicationLifetime"/> or <paramref name="hostOptions"/> or <paramref name="loggerFactory"/> is <see langword="null"/>.</exception>
4650 public ConsoleLifetime ( IOptions < ConsoleLifetimeOptions > options , IHostEnvironment environment , IHostApplicationLifetime applicationLifetime , IOptions < HostOptions > hostOptions , ILoggerFactory loggerFactory )
51+ : this ( options , environment , applicationLifetime , hostOptions , loggerFactory , configuration : null ) { }
52+
53+ // Internal ctor accepting IConfiguration for diagnostic logging. Kept internal to avoid
54+ // adding a new public API surface to a pubternal class; ConsoleLifetime is registered via
55+ // a factory (see HostingHostBuilderExtensions.AddConsoleLifetime) so DI doesn't need
56+ // to pick this ctor automatically.
57+ internal ConsoleLifetime ( IOptions < ConsoleLifetimeOptions > options , IHostEnvironment environment , IHostApplicationLifetime applicationLifetime , IOptions < HostOptions > hostOptions , ILoggerFactory loggerFactory , IConfiguration ? configuration )
4758 {
4859 ArgumentNullException . ThrowIfNull ( options ? . Value , nameof ( options ) ) ;
4960 ArgumentNullException . ThrowIfNull ( applicationLifetime ) ;
@@ -55,6 +66,7 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
5566 Environment = environment ;
5667 ApplicationLifetime = applicationLifetime ;
5768 HostOptions = hostOptions . Value ;
69+ Configuration = configuration ;
5870 Logger = loggerFactory . CreateLogger ( "Microsoft.Hosting.Lifetime" ) ;
5971 }
6072
@@ -66,6 +78,8 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
6678
6779 private HostOptions HostOptions { get ; }
6880
81+ private IConfiguration ? Configuration { get ; }
82+
6983 private ILogger Logger { get ; }
7084
7185 /// <summary>
@@ -77,6 +91,16 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
7791 {
7892 if ( ! Options . SuppressStatusMessages )
7993 {
94+ // Log a diagnostic when the content root is the current working directory and looks
95+ // suspicious. Logging here (rather than in OnApplicationStarted) ensures the message
96+ // is still emitted when the host fails to start (e.g. a hosted service can't find
97+ // appsettings.json because the working directory is unintentionally "/" in a
98+ // container without WORKDIR or when launched by systemd without WorkingDirectory).
99+ if ( Logger . IsEnabled ( LogLevel . Information ) && ShouldWarnAboutContentRoot ( ) )
100+ {
101+ Logger . LogInformation ( "Content root path is the current working directory ({ContentRoot}). To override, set the content root explicitly." , Environment . ContentRootPath ) ;
102+ }
103+
80104 _applicationStartedRegistration = ApplicationLifetime . ApplicationStarted . Register ( state =>
81105 {
82106 ( ( ConsoleLifetime ) state ! ) . OnApplicationStarted ( ) ;
@@ -95,6 +119,106 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
95119 return Task . CompletedTask ;
96120 }
97121
122+ private bool ShouldWarnAboutContentRoot ( )
123+ {
124+ try
125+ {
126+ string contentRootPath = Environment . ContentRootPath ;
127+
128+ // Hosting does not normalize ContentRootPath, so an exact match
129+ // indicates the working directory is being used as the content root.
130+ // A user-specified content root that happens to expand to the same directory
131+ // but as a different string (e.g. with a trailing separator, or via "./")
132+ // is assumed to be intentional.
133+ if ( ! string . Equals ( contentRootPath , Directory . GetCurrentDirectory ( ) , StringComparison . Ordinal ) )
134+ {
135+ return false ;
136+ }
137+
138+ // Case 1: the content root is a filesystem root (e.g. "/" or "C:\").
139+ // Almost certainly not where the user intended their app to be rooted - log
140+ // regardless of how it got set.
141+ if ( string . Equals ( Path . GetPathRoot ( contentRootPath ) , contentRootPath , StringComparison . Ordinal ) )
142+ {
143+ return true ;
144+ }
145+
146+ // Case 2: at least one file-based configuration source is rooted at the content
147+ // root but none of those files exist on disk. This typically means appsettings.json
148+ // was expected (hosting defaults registered it) but the working directory doesn't
149+ // actually contain it - a sign the working directory is not the intended app
150+ // directory.
151+ return AllContentRootFileSourcesAreMissing ( contentRootPath ) ;
152+ }
153+ catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException or SecurityException )
154+ {
155+ // This diagnostic is a heuristic. I/O and security failures from
156+ // querying the current directory or probing file-based configuration providers
157+ // should simply skip the diagnostic.
158+ return false ;
159+ }
160+ }
161+
162+ private bool AllContentRootFileSourcesAreMissing ( string contentRootPath )
163+ {
164+ if ( Configuration is not IConfigurationRoot configRoot )
165+ {
166+ return false ;
167+ }
168+
169+ bool sawContentRootSource = false ;
170+
171+ foreach ( IConfigurationProvider provider in configRoot . Providers )
172+ {
173+ if ( provider is not FileConfigurationProvider fileProvider )
174+ {
175+ continue ;
176+ }
177+
178+ FileConfigurationSource source = fileProvider . Source ;
179+ if ( source . FileProvider is not PhysicalFileProvider physicalProvider )
180+ {
181+ // We can only compare paths against the content root for physical providers.
182+ continue ;
183+ }
184+
185+ if ( ! TrimTrailingDirectorySeparator ( physicalProvider . Root ) . Equals ( contentRootPath . AsSpan ( ) , StringComparison . Ordinal ) )
186+ {
187+ continue ;
188+ }
189+
190+ if ( source . Path is not string sourcePath )
191+ {
192+ continue ;
193+ }
194+
195+ sawContentRootSource = true ;
196+
197+ if ( source . FileProvider . GetFileInfo ( sourcePath ) . Exists )
198+ {
199+ return false ;
200+ }
201+ }
202+
203+ return sawContentRootSource ;
204+ }
205+
206+ private static ReadOnlySpan < char > TrimTrailingDirectorySeparator ( string path )
207+ {
208+ if ( path . Length <= 1 )
209+ {
210+ return path ;
211+ }
212+
213+ char last = path [ path . Length - 1 ] ;
214+ if ( last == Path . DirectorySeparatorChar || last == Path . AltDirectorySeparatorChar )
215+ {
216+ return path . AsSpan ( 0 , path . Length - 1 ) ;
217+ }
218+
219+ return path ;
220+ }
221+
98222 private partial void RegisterShutdownHandlers ( ) ;
99223
100224 private void OnApplicationStarted ( )
0 commit comments