Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 104 additions & 23 deletions src/DotNet.ReproducibleBuilds.Isolated/HostFxrResolver.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
#if NET
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
#if NET
using System.Runtime.Loader;
#endif

namespace DotNet.ReproducibleBuilds.Isolated;

/// <summary>
/// Provides custom resolution for the hostfxr native library.
/// Required for Alpine Linux and other environments where hostfxr is not in the default search paths.
/// Uses AssemblyLoadContext.ResolvingUnmanagedDll event for resolution.
/// </summary>
/// <remarks>
/// Based on https://github.com/microsoft/MSBuildLocator/blob/c16c5354e9fda9703933079528ae67bb5ae4e34e/src/MSBuildLocator/DotNetSdkLocationHelper.cs
/// Based on https://github.com/microsoft/MSBuildLocator/blob/c16c5354e9fda9703933079528ae67bb5ae4e34e/src/MSBuildLocator/DotNetSdkLocationHelper.cs.
///
/// On .NET, this hooks <see cref="AssemblyLoadContext.ResolvingUnmanagedDll"/> so hostfxr is located
/// when the first P/Invoke fails the default loader search. The canonical case is Alpine Linux, where
/// <c>libhostfxr.so</c> lives at <c>{dotnet-root}/host/fxr/{version}/</c> and the loader
/// doesn't probe that path on its own.
///
/// On .NET Framework, AssemblyLoadContext is not available. This branch is what loads hostfxr when
/// the net472 task runs inside Visual Studio 2022+'s in-process MSBuild engine, which is the scenario
/// in dotnet/reproducible-builds#79. The MSBuild engine VS hosts in-process is itself compiled for
/// .NET, so its <c>SdkResolverService</c> short-circuits in-box SDK resolution and never loads the
/// plugin <c>Microsoft.DotNet.MSBuildSdkResolver</c> whose static ctor would have preloaded hostfxr
/// (via <c>Microsoft.DotNet.NativeWrapper.Interop.PreloadWindowsLibrary</c>). Standalone netfx
/// <c>MSBuild.exe</c> is compiled net472, doesn't short-circuit, loads the plugin resolver, and gets
/// the preload for free - which is why <c>dotnet build</c> and command-line <c>MSBuild.exe</c>
/// succeed while VS IDE fails. Without this class hostfxr is never mapped into the netfx process and
/// the default Windows DLL search order won't find it (it lives in a versioned subdirectory of the
/// dotnet install, not on PATH), so we eagerly <c>LoadLibraryExW</c> the highest-version copy we can
/// find.
/// </remarks>
internal static class HostFxrResolver
{
Expand All @@ -23,12 +41,14 @@ internal static class HostFxrResolver
private static readonly Lazy<List<string>> s_dotnetPathCandidates = new(ResolveDotnetPathCandidates);

internal const string HostFxrName = "hostfxr";
private static string ExeName => OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";

private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
private static bool IsMacOS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);

private static string ExeName => IsWindows ? "dotnet.exe" : "dotnet";

/// <summary>
/// Registers a resolver for hostfxr with the current AssemblyLoadContext.
/// This enables hostfxr to be found on Alpine Linux and other environments
/// where it's not in the default search paths.
/// Registers a resolver for hostfxr so that subsequent P/Invokes can find it.
/// </summary>
public static void Register()
{
Expand All @@ -39,11 +59,15 @@ public static void Register()
return;
}

s_resolverAdded = true;

// For Windows hostfxr is loaded in the process.
if (OperatingSystem.IsWindows())
#if NET
// On .NET, this assembly is loaded into a process that's almost certainly hosted by
// dotnet.exe (or a custom apphost), which means hostfxr is already mapped into the
// process and the P/Invoke binds without any help. Skip the resolver in that case.
// Custom .NET hosts that don't preload hostfxr exist but aren't a real scenario for an
// MSBuild task; the AssemblyLoadContext fallback below covers the non-Windows case.
if (IsWindows)
{
s_resolverAdded = true;
return;
}

Expand All @@ -52,9 +76,31 @@ public static void Register()
{
loadContext.ResolvingUnmanagedDll += HostFxrResolver_ResolvingUnmanagedDll;
}

s_resolverAdded = true;
#else
// On .NET Framework hosts where VS's in-process MSBuild bypasses Microsoft.DotNet.MSBuildSdkResolver
// (see class remarks), nothing else has loaded hostfxr. Eagerly LoadLibrary the highest-version
// copy from the installed dotnet runtime so the [DllImport("hostfxr")] in
// ValidateGlobalJsonSdkVersion can bind to the already-loaded module.
//
// This branch is Windows-only - net472 outside Windows isn't a real scenario for this task,
// and LoadLibraryExW lives in kernel32.
if (!IsWindows)
{
s_resolverAdded = true;
return;
}

if (TryEagerLoadHostFxr())
{
s_resolverAdded = true;
}
#endif
}
}

#if NET
private static IntPtr HostFxrResolver_ResolvingUnmanagedDll(Assembly assembly, string libraryName)
{
// The DllImport hardcoded the name as hostfxr.
Expand All @@ -63,9 +109,49 @@ private static IntPtr HostFxrResolver_ResolvingUnmanagedDll(Assembly assembly, s
return IntPtr.Zero;
}

string hostFxrLibName = OperatingSystem.IsWindows()
foreach (string hostFxrAssembly in EnumerateHostFxrCandidates())
{
if (NativeLibrary.TryLoad(hostFxrAssembly, out IntPtr handle))
{
return handle;
}
}

return IntPtr.Zero;
}
#else
private static bool TryEagerLoadHostFxr()
{
foreach (string hostFxrAssembly in EnumerateHostFxrCandidates())
{
// LOAD_WITH_ALTERED_SEARCH_PATH (0x8) tells the loader to resolve hostfxr's own transitive
// dependencies (e.g. hostpolicy.dll, which lives next to hostfxr in the same versioned dir)
// from hostfxr's directory rather than the process's default DLL search path. Matches what
// Microsoft.DotNet.NativeWrapper does when the SDK resolver preloads hostfxr.
if (LoadLibraryExW(hostFxrAssembly, IntPtr.Zero, LOAD_WITH_ALTERED_SEARCH_PATH) != IntPtr.Zero)
{
return true;
}
}

return false;
}

private const int LOAD_WITH_ALTERED_SEARCH_PATH = 0x8;

[DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
private static extern IntPtr LoadLibraryExW(string lpFileName, IntPtr hFile, int dwFlags);
#endif

/// <summary>
/// Enumerates full paths to candidate hostfxr native libraries across all known dotnet install
/// locations, ordered from highest version to lowest within each install root.
/// </summary>
private static IEnumerable<string> EnumerateHostFxrCandidates()
{
string hostFxrLibName = IsWindows
? $"{HostFxrName}.dll"
: OperatingSystem.IsMacOS()
: IsMacOS
? $"lib{HostFxrName}.dylib"
: $"lib{HostFxrName}.so";

Expand All @@ -77,7 +163,6 @@ private static IntPtr HostFxrResolver_ResolvingUnmanagedDll(Assembly assembly, s
continue;
}

// Get version directories and sort descending (newest first)
string[] versionDirs;
try
{
Expand All @@ -102,15 +187,9 @@ private static IntPtr HostFxrResolver_ResolvingUnmanagedDll(Assembly assembly, s

foreach (string versionDir in versionDirs)
{
string hostFxrAssembly = Path.Combine(versionDir, hostFxrLibName);
if (NativeLibrary.TryLoad(hostFxrAssembly, out IntPtr handle))
{
return handle;
}
yield return Path.Combine(versionDir, hostFxrLibName);
}
}

return IntPtr.Zero;
}

private static List<string> ResolveDotnetPathCandidates()
Expand Down Expand Up @@ -184,19 +263,21 @@ void AddIfValid(string? path)
string fullPathToDotnetFromRoot = Path.Combine(dotnetPath!, ExeName);
if (File.Exists(fullPathToDotnetFromRoot))
{
if (!OperatingSystem.IsWindows())
#if NET
if (!IsWindows)
{
string? resolved = File.ResolveLinkTarget(fullPathToDotnetFromRoot, returnFinalTarget: true)?.FullName;
if (!string.IsNullOrEmpty(resolved) && File.Exists(resolved))
{
return Path.GetDirectoryName(resolved);
}
}
#endif

return dotnetPath;
}

return null;
}
}
#endif

Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ namespace DotNet.ReproducibleBuilds.Isolated;
/// </remarks>
public class ValidateGlobalJsonSdkVersion : Task
{
#if NET
static ValidateGlobalJsonSdkVersion()
{
// Register resolver for hostfxr to handle Alpine Linux and other non-standard paths
// Register resolver for hostfxr. On .NET this primarily helps Alpine Linux; on .NET Framework
// (when the task is loaded into an MSBuild process hosted on .NET Framework, e.g. inside VS or
// when MSBuildRuntimeType != Core), this is what makes the [DllImport("hostfxr")] below
// actually resolve.
HostFxrResolver.Register();
}
#endif

/// <summary>
/// The working directory to search for global.json.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@
</ProjectReference>
</ItemGroup>

<!-- The StubTaskHarness is a net472 console app used by HostFxrResolverTests to exercise the hostfxr
P/Invoke from a .NET Framework process. The bug it guards against is Windows-only. -->
<ItemGroup>
<ProjectReference Include="$(RepoRoot)/tests/StubTaskHarness/StubTaskHarness.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
<GlobalPropertiesToRemove>TargetFramework;TargetFrameworks</GlobalPropertiesToRemove>
</ProjectReference>
</ItemGroup>

<!-- HostFxrResolverTests and its ProcessHelpers exercise the net472 task via the StubTaskHarness.
They have no signal on other TFMs and would quadruple wall time and flake surface on Windows
runs, so compile them out for non-net472 targets. -->
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
<Compile Remove="HostFxrResolverTests.cs" />
<Compile Remove="ProcessHelpers.cs" />
</ItemGroup>

<ItemGroup>
<Compile Include="../Shared/*.cs" LinkBase="Shared" />

Expand All @@ -53,6 +71,22 @@
<None Include="$(RepoRoot)/src/DotNet.ReproducibleBuilds.Isolated/bin/$(Configuration)/net6.0/DotNet.ReproducibleBuilds.Isolated.deps.json" CopyToOutputDirectory="PreserveNewest" Link="tasks\net6.0\DotNet.ReproducibleBuilds.Isolated.deps.json" />
</ItemGroup>

<!-- Copy the StubTaskHarness output into the test bin so HostFxrResolverTests can spawn it as a
subprocess. Ask the harness project for its runtime closure rather than assuming a bin layout
that BaseOutputPath / UseArtifactsOutput / etc could relocate. -->
<Target Name="_CopyStubTaskHarnessToOutput"
AfterTargets="CopyFilesToOutputDirectory">
<MSBuild Projects="$(RepoRoot)tests/StubTaskHarness/StubTaskHarness.csproj"
Targets="BuiltProjectOutputGroup;DebugSymbolsProjectOutputGroup;SatelliteDllsProjectOutputGroup;ReferenceCopyLocalPathsOutputGroup"
RemoveProperties="TargetFramework;TargetFrameworks">
<Output TaskParameter="TargetOutputs" ItemName="_StubTaskHarnessOutput" />
</MSBuild>
<Copy SourceFiles="@(_StubTaskHarnessOutput)"
DestinationFiles="@(_StubTaskHarnessOutput->'$(OutDir)harness\%(Filename)%(Extension)')"
SkipUnchangedFiles="true"
OverwriteReadOnlyFiles="true" />
</Target>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Diagnostics;
using System.Runtime.InteropServices;

using FluentAssertions;

namespace DotNet.ReproducibleBuilds.Isolated.Tests;

/// <summary>
/// Validates that the net472 build of the task can resolve the <c>hostfxr</c> native library
/// when MSBuild hosts it on .NET Framework (e.g. Visual Studio's in-process build).
///
/// Regression test for https://github.com/dotnet/reproducible-builds/issues/79: in 2.0.2
/// the <c>HostFxrResolver</c> was compiled only into the <c>net6.0</c> build, so the
/// <c>net472</c> task fell through to Windows' default DLL search and threw
/// <see cref="DllNotFoundException"/> unless an unrelated process had already loaded
/// <c>hostfxr.dll</c>.
/// </summary>
public class HostFxrResolverTests
{
[Fact]
public void NetFrameworkTask_ResolvesHostFxr_WhenNoCopyIsDirectlyOnPath()
{
Assert.SkipUnless(
RuntimeInformation.IsOSPlatform(OSPlatform.Windows),
"Issue #79 is specific to Visual Studio hosting MSBuild on .NET Framework, which only happens on Windows.");

string testBin = AppContext.BaseDirectory;
string harnessExe = Path.Combine(testBin, "harness", "StubTaskHarness.exe");
string taskDll = Path.Combine(testBin, "tasks", "net472", "DotNet.ReproducibleBuilds.Isolated.dll");

File.Exists(harnessExe).Should().BeTrue($"the harness must be copied into the test bin (looked at {harnessExe})");
File.Exists(taskDll).Should().BeTrue($"the net472 task DLL must be copied into the test bin (looked at {taskDll})");

string dotnetDir = FindDotnetExeDirectory();
string systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
string system32 = Environment.GetFolderPath(Environment.SpecialFolder.System);
string path = string.Join(";", dotnetDir, system32, systemRoot);

// Guard against the test accidentally going green: hostfxr.dll must not be discoverable
// through Windows' default DLL search, otherwise the P/Invoke would succeed even without
// the resolver. The resolver must reach `<dotnet>\host\fxr\<ver>\hostfxr.dll` for the
// load to succeed.
AssertNoHostFxrIn(Path.GetDirectoryName(harnessExe)!);
AssertNoHostFxrIn(Path.GetDirectoryName(taskDll)!);
AssertNoHostFxrIn(dotnetDir);
AssertNoHostFxrIn(system32);
AssertNoHostFxrIn(systemRoot);

var psi = new ProcessStartInfo(harnessExe, $"\"{taskDll}\"")
{
WorkingDirectory = systemRoot,
};

// Build a controlled, minimal environment that mirrors the reporter's scenario:
// - PATH has `dotnet.exe` (so HostFxrResolver can locate dotnet via PATH) and the system dirs,
// but no directory that contains `hostfxr.dll` directly (e.g. PowerShell 7's copy is excluded).
// - All DOTNET_* discovery shortcuts are cleared so the test exercises the PATH-based
// probing path that VS-hosted MSBuild typically uses.
psi.EnvironmentVariables["PATH"] = path;
psi.EnvironmentVariables.Remove("DOTNET_ROOT");
psi.EnvironmentVariables.Remove("DOTNET_ROOT(x86)");
psi.EnvironmentVariables.Remove("DOTNET_ROOT(arm64)");
psi.EnvironmentVariables.Remove("DOTNET_HOST_PATH");
psi.EnvironmentVariables.Remove("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR");

var (exitCode, stdout, stderr) = ProcessHelpers.RunAndWaitForExit(psi);

exitCode.Should().Be(
0,
$"the net472 task must resolve hostfxr through HostFxrResolver.{Environment.NewLine}" +
$"stdout: {stdout}{Environment.NewLine}" +
$"stderr: {stderr}");
}

private static void AssertNoHostFxrIn(string directory)
{
string candidate = Path.Combine(directory, "hostfxr.dll");
File.Exists(candidate).Should().BeFalse(
$"hostfxr.dll at '{candidate}' would let Windows' default DLL search satisfy the P/Invoke, " +
"which would make this test pass even without a working HostFxrResolver.");
}

private static string FindDotnetExeDirectory()
{
string? pathEnv = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrEmpty(pathEnv))
{
foreach (string dir in pathEnv!.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir))
{
continue;
}
string candidate = Path.Combine(dir, "dotnet.exe");
if (File.Exists(candidate))
{
return Path.GetDirectoryName(candidate)!;
}
}
}

throw new InvalidOperationException(
"Environmental precondition not met: dotnet.exe is not on PATH. " +
"HostFxrResolver requires PATH-based discovery to find the .NET install. " +
"Install the .NET SDK and ensure dotnet.exe is on PATH before running this test.");
}
}
Loading