diff --git a/src/Common/HostFxrPathResolver.cs b/src/Common/HostFxrPathResolver.cs new file mode 100644 index 000000000000..99d9525b41f3 --- /dev/null +++ b/src/Common/HostFxrPathResolver.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using NuGet.Versioning; + +namespace Microsoft.DotNet.HostFxr; + +/// +/// Locates the native hostfxr library underneath a .NET installation root. +/// This is used as a fallback when the host does not publish the HOSTFXR_PATH +/// runtime property (for example when the SDK is launched via dotnet exec dotnet.dll +/// rather than as a first-class SDK command, which is how the dnx script works). +/// Dependencies are injected so the pure path-resolution logic can be unit tested. +/// +internal static class HostFxrPathResolver +{ + /// + /// Finds the highest-versioned hostfxr library under + /// /host/fxr, returning its full path or + /// if it cannot be found. + /// + internal static string ResolveHostFxrPath( + string? dotnetRoot, + bool isWindows, + bool isMacOS, + Func directoryExists, + Func getDirectories, + Func fileExists) + { + if (string.IsNullOrEmpty(dotnetRoot)) + { + return string.Empty; + } + + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + if (!directoryExists(fxrDir)) + { + return string.Empty; + } + + // Match the native host's get_latest_fxr behavior: consider every valid + // semantic version, including prerelease versions, and select the highest. + // SDK and runtime roll-forward settings apply after hostfxr is loaded and + // do not affect hostfxr selection. + string? latestFxr = getDirectories(fxrDir) + .Select(path => + { + SemanticVersion.TryParse(Path.GetFileName(path), out SemanticVersion? version); + return new + { + Path = path, + Version = version + }; + }) + .Where(candidate => candidate.Version is not null) + .OrderByDescending(candidate => candidate.Version) + .Select(candidate => candidate.Path) + .FirstOrDefault(); + + if (latestFxr is null) + { + return string.Empty; + } + + string hostfxrName = isWindows + ? "hostfxr.dll" + : isMacOS + ? "libhostfxr.dylib" + : "libhostfxr.so"; + + string hostfxrPath = Path.Combine(latestFxr, hostfxrName); + return fileExists(hostfxrPath) ? hostfxrPath : string.Empty; + } +} diff --git a/src/Resolvers/Microsoft.DotNet.NativeWrapper/HostFxrLocator.cs b/src/Resolvers/Microsoft.DotNet.NativeWrapper/HostFxrLocator.cs new file mode 100644 index 000000000000..33f1dd496d56 --- /dev/null +++ b/src/Resolvers/Microsoft.DotNet.NativeWrapper/HostFxrLocator.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NETCOREAPP +using Microsoft.DotNet.HostFxr; + +namespace Microsoft.DotNet.NativeWrapper +{ + /// + /// NativeWrapper-facing entry point for locating the native hostfxr library. + /// The actual path-resolution logic lives in + /// under src/Common. + /// + internal static class HostFxrLocator + { + /// + /// Finds the highest-versioned hostfxr library under + /// /host/fxr, returning its full path or + /// if it cannot be found. + /// + internal static string ResolveHostFxrPath( + string? dotnetRoot, + bool isWindows, + bool isMacOS, + Func directoryExists, + Func getDirectories, + Func fileExists) + => HostFxrPathResolver.ResolveHostFxrPath( + dotnetRoot, + isWindows, + isMacOS, + directoryExists, + getDirectories, + fileExists); + + /// + /// Resolves the hostfxr path against the live filesystem, deriving the .NET + /// installation root from the current process (the running dotnet host). + /// + internal static string ResolveHostFxrPath() + => ResolveHostFxrPath( + EnvironmentProvider.GetDotnetExeDirectory(), + isWindows: OperatingSystem.IsWindows(), + isMacOS: OperatingSystem.IsMacOS(), + directoryExists: Directory.Exists, + getDirectories: Directory.GetDirectories, + fileExists: File.Exists); + } +} +#endif diff --git a/src/Resolvers/Microsoft.DotNet.NativeWrapper/Interop.cs b/src/Resolvers/Microsoft.DotNet.NativeWrapper/Interop.cs index 93be3037a8c8..f0e9c6ce0caa 100644 --- a/src/Resolvers/Microsoft.DotNet.NativeWrapper/Interop.cs +++ b/src/Resolvers/Microsoft.DotNet.NativeWrapper/Interop.cs @@ -49,14 +49,26 @@ private static IntPtr HostFxrResolver(Assembly assembly, string libraryName) return IntPtr.Zero; } - if (string.IsNullOrEmpty(HostFxrPath)) + string? hostFxrPath = HostFxrPath; + + // The host only publishes the HOSTFXR_PATH runtime property for first-class SDK + // commands (e.g. `dotnet build`). When the SDK is launched via `dotnet exec dotnet.dll` + // (as the `dnx` script does), the property is absent. On glibc the bare `libhostfxr` + // load succeeds against the already-loaded library so this resolver never runs, but on + // musl it does not, so fall back to locating hostfxr under the running .NET root. + if (string.IsNullOrEmpty(hostFxrPath)) + { + hostFxrPath = HostFxrLocator.ResolveHostFxrPath(); + } + + if (string.IsNullOrEmpty(hostFxrPath)) { throw new HostFxrRuntimePropertyNotSetException(); } - if (!NativeLibrary.TryLoad(HostFxrPath, out var handle)) + if (!NativeLibrary.TryLoad(hostFxrPath, out var handle)) { - throw new HostFxrNotFoundException(HostFxrPath); + throw new HostFxrNotFoundException(hostFxrPath); } return handle; diff --git a/src/Resolvers/Microsoft.DotNet.NativeWrapper/Microsoft.DotNet.NativeWrapper.csproj b/src/Resolvers/Microsoft.DotNet.NativeWrapper/Microsoft.DotNet.NativeWrapper.csproj index ec780cd24208..89a840912688 100644 --- a/src/Resolvers/Microsoft.DotNet.NativeWrapper/Microsoft.DotNet.NativeWrapper.csproj +++ b/src/Resolvers/Microsoft.DotNet.NativeWrapper/Microsoft.DotNet.NativeWrapper.csproj @@ -6,6 +6,15 @@ + + + + + + + + + diff --git a/test/dotnet.Tests/NativeWrapperTests/HostFxrLocatorTests.cs b/test/dotnet.Tests/NativeWrapperTests/HostFxrLocatorTests.cs new file mode 100644 index 000000000000..90fcfdb56388 --- /dev/null +++ b/test/dotnet.Tests/NativeWrapperTests/HostFxrLocatorTests.cs @@ -0,0 +1,227 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.NativeWrapper; + +namespace Microsoft.DotNet.Cli.Tests; + +public class HostFxrLocatorTests +{ + private static string BuildPath(bool isWindows, params string[] segments) + { + string root = isWindows ? @"C:\" : "/"; + return segments.Length == 0 ? root : Path.Combine(root, Path.Combine(segments)); + } + + [Fact] + public void ResolveHostFxrPath_WithValidFxrDir_ReturnsPath() + { + string dotnetRoot = BuildPath(true, "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string fxrVersion = Path.Combine(fxrDir, "11.0.0"); + string expectedPath = Path.Combine(fxrVersion, "hostfxr.dll"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: true, + isMacOS: false, + directoryExists: path => path == fxrDir, + getDirectories: _ => [fxrVersion], + fileExists: path => path == expectedPath); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_PicksHighestVersion() + { + string dotnetRoot = BuildPath(true, "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string v800 = Path.Combine(fxrDir, "8.0.0"); + string v901 = Path.Combine(fxrDir, "9.0.1"); + string v900 = Path.Combine(fxrDir, "9.0.0"); + string expectedPath = Path.Combine(v901, "hostfxr.dll"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [v800, v901, v900], + fileExists: _ => true); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_NullOrEmptyDotnetRoot_ReturnsEmpty() + { + string resultNull = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: null, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [], + fileExists: _ => true); + + string resultEmpty = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: string.Empty, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [], + fileExists: _ => true); + + resultNull.Should().BeEmpty(); + resultEmpty.Should().BeEmpty(); + } + + [Fact] + public void ResolveHostFxrPath_MissingFxrDirectory_ReturnsEmpty() + { + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: BuildPath(true, "dotnet"), + isWindows: true, + isMacOS: false, + directoryExists: _ => false, + getDirectories: _ => [], + fileExists: _ => false); + + result.Should().BeEmpty(); + } + + [Fact] + public void ResolveHostFxrPath_FxrDirExistsButNoHostfxrFile_ReturnsEmpty() + { + string dotnetRoot = BuildPath(true, "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string fxrVersion = Path.Combine(fxrDir, "9.0.0"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [fxrVersion], + fileExists: _ => false); + + result.Should().BeEmpty(); + } + + [Fact] + public void ResolveHostFxrPath_OnMacOS_LooksForDylib() + { + string dotnetRoot = Path.Combine("/", "usr", "local", "share", "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string fxrVersion = Path.Combine(fxrDir, "9.0.0"); + string expectedPath = Path.Combine(fxrVersion, "libhostfxr.dylib"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: false, + isMacOS: true, + directoryExists: path => path == fxrDir, + getDirectories: _ => [fxrVersion], + fileExists: path => path == expectedPath); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_OnLinux_LooksForSo() + { + string dotnetRoot = Path.Combine("/", "usr", "share", "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string fxrVersion = Path.Combine(fxrDir, "9.0.0"); + string expectedPath = Path.Combine(fxrVersion, "libhostfxr.so"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: false, + isMacOS: false, + directoryExists: path => path == fxrDir, + getDirectories: _ => [fxrVersion], + fileExists: path => path == expectedPath); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_FindsPrereleaseVersionDirectory() + { + string dotnetRoot = Path.Combine("/", "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string fxrVersion = Path.Combine(fxrDir, "11.0.0-preview.6.26359.118"); + string expectedPath = Path.Combine(fxrVersion, "libhostfxr.so"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: false, + isMacOS: false, + directoryExists: path => path == fxrDir, + getDirectories: _ => [fxrVersion], + fileExists: path => path == expectedPath); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_PicksHighestVersion_IncludingPrerelease() + { + string dotnetRoot = BuildPath(true, "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string v900 = Path.Combine(fxrDir, "9.0.0"); + string v11Preview = Path.Combine(fxrDir, "11.0.0-preview.6.26359.118"); + string expectedPath = Path.Combine(v11Preview, "hostfxr.dll"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [v900, v11Preview], + fileExists: _ => true); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_PrefersStableOverPrereleaseOfSameCore() + { + string dotnetRoot = BuildPath(true, "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string stable = Path.Combine(fxrDir, "11.0.0"); + string preview = Path.Combine(fxrDir, "11.0.0-preview.6.26359.118"); + string expectedPath = Path.Combine(stable, "hostfxr.dll"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [preview, stable], + fileExists: _ => true); + + result.Should().Be(expectedPath); + } + + [Fact] + public void ResolveHostFxrPath_OrdersPrereleaseSegmentsNumerically() + { + string dotnetRoot = BuildPath(true, "dotnet"); + string fxrDir = Path.Combine(dotnetRoot, "host", "fxr"); + string preview6 = Path.Combine(fxrDir, "11.0.0-preview.6.26359.118"); + string preview10 = Path.Combine(fxrDir, "11.0.0-preview.10.26400.1"); + string expectedPath = Path.Combine(preview10, "hostfxr.dll"); + + string result = HostFxrLocator.ResolveHostFxrPath( + dotnetRoot: dotnetRoot, + isWindows: true, + isMacOS: false, + directoryExists: _ => true, + getDirectories: _ => [preview6, preview10], + fileExists: _ => true); + + result.Should().Be(expectedPath); + } +} diff --git a/test/dotnet.Tests/dotnet.Tests.csproj b/test/dotnet.Tests/dotnet.Tests.csproj index 99b8a5c81e4d..614eec41df38 100644 --- a/test/dotnet.Tests/dotnet.Tests.csproj +++ b/test/dotnet.Tests/dotnet.Tests.csproj @@ -73,6 +73,7 @@ +