This is an automated email from the ASF dual-hosted git repository. FreeAndNil pushed a commit to branch Feature/306-aot-calling-assembly in repository https://gitbox.apache.org/repos/asf/logging-log4net.git
commit 26841b26341658423f813e72f70fcdb4675601ff Author: Jan Friedrich <[email protected]> AuthorDate: Wed Aug 5 23:06:59 2026 +0200 make log4net usable from a PublishAOT build (#306) Native AOT broke two things in the startup path (fixes #233 partially). Assembly.GetCallingAssembly() throws PlatformNotSupportedException there, so every overload that resolves the repository from the caller failed - LogManager.GetLogger(Type) among them. Guard the 18 call sites with CallerAssembly.IsSupported, a flag probed once, and fall back to the entry assembly when the runtime does not implement the call. The call itself has to stay in the public method whose caller is wanted, so it cannot be moved into the helper. SystemInfo.GetAppSetting() then failed as well, because System.Configuration is trimmed away. Its catch never saw that: resolving the missing assembly fails on entry to the method, before the try region, so the exception escaped the static constructor as a TypeInitializationException and killed the process. Read the setting through a separate, never inlined method so the failure is raised inside the try block, latch the result so a permanent failure is reported once rather than per lookup, and fall back to environment variables the way the Android branch already does. That fallback also makes log4net.NullText and log4net.NotAvailableText settable under AOT, where they previously could not be configured at all. Note that the fallback applies on .NET Framework too: a malformed app.config now reads settings from the environment instead of returning null. This does not make log4net AOT-clean - repositories, appenders and layouts are still instantiated via Activator.CreateInstance, so an AOT app still fails with MissingMethodException on Hierarchy's constructor. --- src/log4net.Tests/Util/CallerAssemblyTest.cs | 66 +++++++++++++++++++++++++ src/log4net.Tests/Util/SystemInfoTest.cs | 64 ++++++++++++++++++++++++ src/log4net.Tests/log4net.Tests.csproj | 1 + src/log4net/Config/BasicConfigurator.cs | 5 +- src/log4net/Config/XmlConfigurator.cs | 14 +++--- src/log4net/LogManager.cs | 26 ++++++---- src/log4net/Util/CallerAssembly.cs | 74 ++++++++++++++++++++++++++++ src/log4net/Util/SystemInfo.cs | 44 ++++++++++++++--- 8 files changed, 270 insertions(+), 24 deletions(-) diff --git a/src/log4net.Tests/Util/CallerAssemblyTest.cs b/src/log4net.Tests/Util/CallerAssemblyTest.cs new file mode 100644 index 00000000..3952b057 --- /dev/null +++ b/src/log4net.Tests/Util/CallerAssemblyTest.cs @@ -0,0 +1,66 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +using System.Reflection; +using System.Runtime.CompilerServices; +using log4net.Util; +using NUnit.Framework; + +namespace log4net.Tests.Util; + +/// <summary> +/// Tests for <see cref="CallerAssembly"/>, the guard that keeps the +/// <see cref="Assembly.GetCallingAssembly()"/> based overloads usable under Native AOT. +/// </summary> +/// <remarks> +/// <para> +/// The AOT half of the behaviour cannot be covered here - these tests always run on a JIT +/// runtime, where <see cref="CallerAssembly.IsSupported"/> is <see langword="true"/> and +/// <see cref="CallerAssembly.Fallback"/> is never consulted. What they do cover is that the +/// guard stays inert on a JIT runtime, so that no call site silently starts attributing +/// loggers to the entry assembly instead of the caller. +/// </para> +/// </remarks> +[TestFixture] +public class CallerAssemblyTest +{ + [Test] + public void IsSupportedOnAJitRuntime() => Assert.That(CallerAssembly.IsSupported, Is.True); + + [Test] + public void FallbackIsAvailable() => Assert.That(CallerAssembly.Fallback, Is.Not.Null); + + /// <summary> + /// The guard has to leave <see cref="Assembly.GetCallingAssembly()"/> in the method whose + /// caller is wanted, so a call from this assembly still resolves to this assembly. + /// </summary> + [Test] + public void GuardedCallStillReportsTheCallersAssembly() + => Assert.That(GuardedCallingAssembly(), Is.SameAs(typeof(CallerAssemblyTest).Assembly)); + + /// <summary> + /// Stands in for a public log4net entry point. Inlining is suppressed because it would + /// hand <see cref="Assembly.GetCallingAssembly()"/> a different frame - the same effect + /// that makes the release build of <see cref="SystemInfoTest"/> unable to assert on an + /// exact assembly. + /// </summary> + [MethodImpl(MethodImplOptions.NoInlining)] + private static Assembly GuardedCallingAssembly() + => CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; +} diff --git a/src/log4net.Tests/Util/SystemInfoTest.cs b/src/log4net.Tests/Util/SystemInfoTest.cs index 9b9067c9..e1a7977d 100644 --- a/src/log4net.Tests/Util/SystemInfoTest.cs +++ b/src/log4net.Tests/Util/SystemInfoTest.cs @@ -171,4 +171,68 @@ public void EqualsIgnoringCase_DifferentStrings_false() [Platform(Include = "Win,Linux,MacOsX")] public void IsAndoid() => Assert.That(typeof(SystemInfo).GetProperty("IsAndroid", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null), Is.False); + + /// <summary> + /// <see cref="SystemInfo.GetAppSetting"/> falls back to environment variables once the + /// configuration system has failed - which is what happens under Native AOT, where + /// System.Configuration is trimmed away. + /// </summary> + /// <remarks> + /// <para> + /// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped + /// directly, the same way <see cref="IsAndoid"/> reaches a non-public member. The environment + /// must stay untouched while the configuration system still works, otherwise a malformed + /// <c>app.config</c> would silently change where every setting comes from. + /// </para> + /// </remarks> + [Test] + [NonParallelizable] + public void GetAppSettingFallsBackToTheEnvironmentOnceConfigurationIsUnavailable() + { + const string Key = "log4net.Tests.AppSettingFallback"; + const string Value = "from-the-environment"; + + FieldInfo latch = AppSettingsUnavailableLatch(); + bool originalLatch = (bool)latch.GetValue(null)!; + Environment.SetEnvironmentVariable(Key, Value); + try + { + latch.SetValue(null, false); + Assert.That(SystemInfo.GetAppSetting(Key), Is.Null); + + latch.SetValue(null, true); + Assert.That(SystemInfo.GetAppSetting(Key), Is.EqualTo(Value)); + } + finally + { + latch.SetValue(null, originalLatch); + Environment.SetEnvironmentVariable(Key, null); + } + } + + /// <summary> + /// A key that is missing from the environment as well reads as <see langword="null"/>, so the + /// fallback leaves callers with the same "no such setting" answer they get from a working + /// configuration system. + /// </summary> + [Test] + [NonParallelizable] + public void GetAppSettingReturnsNullForAnUnsetEnvironmentVariable() + { + FieldInfo latch = AppSettingsUnavailableLatch(); + bool originalLatch = (bool)latch.GetValue(null)!; + try + { + latch.SetValue(null, true); + Assert.That(SystemInfo.GetAppSetting("log4net.Tests.NoSuchSettingAnywhere"), Is.Null); + } + finally + { + latch.SetValue(null, originalLatch); + } + } + + private static FieldInfo AppSettingsUnavailableLatch() + => typeof(SystemInfo).GetField("_appSettingsUnavailable", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SystemInfo._appSettingsUnavailable no longer exists - update this test along with it."); } \ No newline at end of file diff --git a/src/log4net.Tests/log4net.Tests.csproj b/src/log4net.Tests/log4net.Tests.csproj index 1ce53b95..f9668c7a 100644 --- a/src/log4net.Tests/log4net.Tests.csproj +++ b/src/log4net.Tests/log4net.Tests.csproj @@ -19,6 +19,7 @@ <VSTestLogger>quackers</VSTestLogger> </PropertyGroup> <ItemGroup> + <Compile Include="..\log4net\Util\CallerAssembly.cs" Link="Util\CallerAssembly.cs" /> <Compile Include="..\log4net\Diagnostics\CodeAnalysis\CallerArgumentExpressionAttribute.cs" Link="Diagnostics\CodeAnalysis\CallerArgumentExpressionAttribute.cs" /> <Compile Include="..\log4net\Diagnostics\CodeAnalysis\IsExternalInit.cs" Link="Diagnostics\CodeAnalysis\IsExternalInit.cs" /> </ItemGroup> diff --git a/src/log4net/Config/BasicConfigurator.cs b/src/log4net/Config/BasicConfigurator.cs index ea7dfa68..2af06e67 100644 --- a/src/log4net/Config/BasicConfigurator.cs +++ b/src/log4net/Config/BasicConfigurator.cs @@ -73,7 +73,8 @@ public static class BasicConfigurator /// layout style. /// </para> /// </remarks> - public static ICollection Configure() => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); + public static ICollection Configure() + => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); /// <summary> /// Initializes the log4net system using the specified appenders. @@ -88,7 +89,7 @@ public static ICollection Configure(params IAppender[] appenders) { List<LogLog> configurationMessages = new(); - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { diff --git a/src/log4net/Config/XmlConfigurator.cs b/src/log4net/Config/XmlConfigurator.cs index fa060cb1..e7ffd2db 100644 --- a/src/log4net/Config/XmlConfigurator.cs +++ b/src/log4net/Config/XmlConfigurator.cs @@ -140,7 +140,7 @@ private static void InternalConfigure(ILoggerRepository repository, Func<XmlElem /// </remarks> /// <seealso cref="Log4NetConfigurationSectionHandler"/> public static ICollection Configure() - => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); + => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); /// <summary> /// Configures log4net using a <c>log4net</c> element @@ -156,7 +156,7 @@ public static ICollection Configure(XmlElement element) { List<LogLog> configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { @@ -222,9 +222,11 @@ public static ICollection Configure(FileInfo configFile) { List<LogLog> configurationMessages = []; + Assembly repositoryAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; + using (new LogLog.LogReceivedAdapter(configurationMessages)) { - InternalConfigure(LogManager.GetRepository(Assembly.GetCallingAssembly()), configFile); + InternalConfigure(LogManager.GetRepository(repositoryAssembly), configFile); } return configurationMessages; @@ -248,7 +250,7 @@ public static ICollection Configure(Uri configUri) { List<LogLog> configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { InternalConfigure(repository, configUri); @@ -277,7 +279,7 @@ public static ICollection Configure(Stream configStream) { List<LogLog> configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { InternalConfigure(repository, configStream); @@ -644,7 +646,7 @@ public static ICollection ConfigureAndWatch(FileInfo configFile) { List<LogLog> configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { diff --git a/src/log4net/LogManager.cs b/src/log4net/LogManager.cs index cc255a54..07185ee9 100644 --- a/src/log4net/LogManager.cs +++ b/src/log4net/LogManager.cs @@ -71,7 +71,8 @@ public static class LogManager /// </remarks> /// <param name="name">The fully qualified logger name to look for.</param> /// <returns>The logger found, or <see langword="null"/> if no logger could be found.</returns> - public static ILog? Exists(string name) => Exists(Assembly.GetCallingAssembly(), name); + public static ILog? Exists(string name) + => Exists(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name); /// <overloads>Get the currently defined loggers.</overloads> /// <summary> @@ -81,7 +82,8 @@ public static class LogManager /// <para>The root logger is <b>not</b> included in the returned array.</para> /// </remarks> /// <returns>All the defined loggers.</returns> - public static ILog[] GetCurrentLoggers() => GetCurrentLoggers(Assembly.GetCallingAssembly()); + public static ILog[] GetCurrentLoggers() + => GetCurrentLoggers(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// <overloads>Get or create a logger.</overloads> /// <summary> @@ -101,7 +103,8 @@ public static class LogManager /// </remarks> /// <param name="name">The name of the logger to retrieve.</param> /// <returns>The logger with the name specified.</returns> - public static ILog GetLogger(string name) => GetLogger(Assembly.GetCallingAssembly(), name); + public static ILog GetLogger(string name) + => GetLogger(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name); /// <summary> /// Returns the named logger if it exists. @@ -211,7 +214,7 @@ public static ILog GetLogger(Assembly repositoryAssembly, string name) /// <param name="type">The full name of <paramref name="type"/> will be used as the name of the logger to retrieve.</param> /// <returns>The logger with the name specified.</returns> public static ILog GetLogger(Type type) - => GetLogger(Assembly.GetCallingAssembly(), type.EnsureNotNull().FullName!); + => GetLogger(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, type.EnsureNotNull().FullName!); /// <summary> /// Shorthand for <see cref="GetLogger(string)"/>. @@ -277,7 +280,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// and again to a nested appender. /// </para> /// </remarks> - public static void ShutdownRepository() => ShutdownRepository(Assembly.GetCallingAssembly()); + public static void ShutdownRepository() + => ShutdownRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// <summary> /// Shuts down the repository for the repository specified. @@ -339,7 +343,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// message disabling is set to its default "off" value. /// </para> /// </remarks> - public static void ResetConfiguration() => ResetConfiguration(Assembly.GetCallingAssembly()); + public static void ResetConfiguration() + => ResetConfiguration(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// <summary> /// Resets all values contained in this repository instance to their defaults. @@ -384,7 +389,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// </para> /// </remarks> /// <returns>The <see cref="ILoggerRepository"/> instance for the default repository.</returns> - public static ILoggerRepository GetRepository() => GetRepository(Assembly.GetCallingAssembly()); + public static ILoggerRepository GetRepository() + => GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// <summary> /// Returns the default <see cref="ILoggerRepository"/> instance. @@ -427,7 +433,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// the same repository instance. /// </para> /// </remarks> - public static ILoggerRepository CreateRepository(Type repositoryType) => CreateRepository(Assembly.GetCallingAssembly(), repositoryType); + public static ILoggerRepository CreateRepository(Type repositoryType) + => CreateRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, repositoryType); /// <summary> /// Creates a repository with the specified name. @@ -499,7 +506,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// <returns><see langword="true"/> if all logging events were flushed successfully, else <see langword="false"/>.</returns> public static bool Flush(int millisecondsTimeout) { - if (LoggerManager.GetRepository(Assembly.GetCallingAssembly()) is not IFlushable flushableRepository) + Assembly callerAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; + if (LoggerManager.GetRepository(callerAssembly) is not IFlushable flushableRepository) { return false; } diff --git a/src/log4net/Util/CallerAssembly.cs b/src/log4net/Util/CallerAssembly.cs new file mode 100644 index 00000000..424a5b1d --- /dev/null +++ b/src/log4net/Util/CallerAssembly.cs @@ -0,0 +1,74 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +using System; +using System.Reflection; + +namespace log4net.Util; + +/// <summary> +/// Support for the <see cref="Assembly.GetCallingAssembly()"/> calls that select the +/// <see cref="Repository.ILoggerRepository"/> of the caller. +/// </summary> +/// <remarks> +/// <para> +/// Native AOT does not implement <see cref="Assembly.GetCallingAssembly()"/> - it throws +/// <see cref="PlatformNotSupportedException"/> unconditionally, because the stack frames it +/// would have to walk no longer exist after compilation. Callers therefore have to test +/// <see cref="IsSupported"/> and use <see cref="Fallback"/> instead. +/// </para> +/// <para> +/// The test cannot be hidden behind a helper that calls <see cref="Assembly.GetCallingAssembly()"/> +/// itself: the calling assembly of such a helper is log4net, not the assembly that called log4net. +/// <see cref="Assembly.GetCallingAssembly()"/> has to stay in the public method whose caller is +/// wanted, so this class only supplies the flag and the replacement value. +/// </para> +/// </remarks> +internal static class CallerAssembly +{ + /// <summary> + /// Whether <see cref="Assembly.GetCallingAssembly()"/> works on the current runtime. + /// </summary> + internal static bool IsSupported { get; } = Probe(); + + /// <summary> + /// The assembly to attribute a call to when <see cref="IsSupported"/> is <see langword="false"/>. + /// </summary> + /// <remarks> + /// <para> + /// The entry assembly is the closest available stand-in: an application published with + /// Native AOT is self-contained, so its loggers would almost always have ended up in the + /// entry assembly's repository anyway. Hosts without a managed entry point fall back to + /// log4net itself, which yields the default repository. + /// </para> + /// </remarks> + internal static Assembly Fallback { get; } = Assembly.GetEntryAssembly() ?? typeof(CallerAssembly).Assembly; + + private static bool Probe() + { + try + { + return Assembly.GetCallingAssembly() is not null; + } + catch (PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/src/log4net/Util/SystemInfo.cs b/src/log4net/Util/SystemInfo.cs index d5b9f9d3..06991d9f 100644 --- a/src/log4net/Util/SystemInfo.cs +++ b/src/log4net/Util/SystemInfo.cs @@ -22,6 +22,7 @@ using System.Reflection; using System.IO; using System.Collections; +using System.Runtime.CompilerServices; namespace log4net.Util; @@ -475,7 +476,7 @@ public static string AssemblyFileName(Assembly myAssembly) /// </para> /// </remarks> public static Type? GetTypeFromString(string typeName, bool throwOnError, bool ignoreCase) - => GetTypeFromString(Assembly.GetCallingAssembly(), typeName, throwOnError, ignoreCase); + => GetTypeFromString(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, typeName, throwOnError, ignoreCase); /// <summary> /// Loads the type specified in the type string. @@ -680,20 +681,49 @@ public static bool TryParse(string s, out short val) /// <returns>the value for the key, or <see langword="null"/></returns> public static string? GetAppSetting(string key) { - if (IsAndroid) - return Environment.GetEnvironmentVariable(key); // Android does not support config files + // Android does not support config files, and neither does a runtime that has trimmed the + // configuration system away. + if (IsAndroid || _appSettingsUnavailable) + return Environment.GetEnvironmentVariable(key); try { - return ConfigurationManager.AppSettings[key]; + return ReadAppSetting(key); } catch (Exception e) when (!e.IsFatal()) { - // If an exception is thrown here then it looks like the config file does not parse correctly. - LogLog.Error(_declaringType, "Exception while reading ConfigurationSettings. Check your .config file is well formed XML.", e); + // Either the config file does not parse correctly, or there is no configuration system to + // read it with - Native AOT trims System.Configuration away. Neither one gets better on the + // next key, so stop asking instead of reporting the same failure for every lookup. + _appSettingsUnavailable = true; + LogLog.Error(_declaringType, + """ + Exception while reading ConfigurationSettings. Check your .config file is well formed XML. + Falling back to environment variables for this and all further application settings. + """, e); } - return null; + return Environment.GetEnvironmentVariable(key); } + /// <summary> + /// Reads a single application setting. + /// </summary> + /// <param name="key">the application settings key to lookup</param> + /// <returns>the value for the key, or <see langword="null"/></returns> + /// <remarks> + /// <para> + /// Separate from <see cref="GetAppSetting"/>, and never inlined into it, so that the failure to + /// resolve <see cref="ConfigurationManager"/> itself is raised on entry to this method - inside + /// the caller's try block - rather than on entry to <see cref="GetAppSetting"/>, where nothing + /// would catch it. Native AOT drops the assembly, and the resulting + /// <see cref="System.IO.FileNotFoundException"/> otherwise escapes the static constructor as a + /// <see cref="TypeInitializationException"/>. + /// </para> + /// </remarks> + [MethodImpl(MethodImplOptions.NoInlining)] + private static string? ReadAppSetting(string key) => ConfigurationManager.AppSettings[key]; + + private static bool _appSettingsUnavailable; + /// <summary> /// Convert a path into a fully qualified local file path. /// </summary>
