Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/Compilers/Core/MSBuildTask/ManagedToolTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ protected override bool ValidateParameters()
{
// Set DOTNET_ROOT so that the apphost executables launch properly.
// Unset all other DOTNET_ROOT* variables so for example DOTNET_ROOT_X64 does not override ours.
if (RuntimeHostInfo.GetToolDotNetRoot() is { } dotNetRoot)
if (RuntimeHostInfo.GetToolDotNetRoot(Log.LogMessage) is { } dotNetRoot)
{
Log.LogMessage("Setting {0} to '{1}'", RuntimeHostInfo.DotNetRootEnvironmentName, dotNetRoot);
EnvironmentVariables =
Expand Down
112 changes: 112 additions & 0 deletions src/Compilers/Core/MSBuildTaskTests/RuntimeHostInfoTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.IO;
using System.Runtime.InteropServices;
using Microsoft.CodeAnalysis.Test.Utilities;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.CodeAnalysis.BuildTasks.UnitTests;

public sealed class RuntimeHostInfoTests(ITestOutputHelper output)
{
private readonly ITestOutputHelper _output = output;

[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
public void DotNetInPath()
{
var previousPath = Environment.GetEnvironmentVariable("PATH");
try
{
using var tempRoot = new TempRoot();
var testDir = tempRoot.CreateDirectory();
var globalDotNetDir = testDir.CreateDirectory("global-dotnet");
var globalDotNetExe = globalDotNetDir.CreateFile($"dotnet{PlatformInformation.ExeExtension}");
Environment.SetEnvironmentVariable("PATH", globalDotNetDir.Path);

Assert.Equal(globalDotNetDir.Path, RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
}
finally
{
Environment.SetEnvironmentVariable("PATH", previousPath);
}
}

[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
public void DotNetInPath_None()
{
var previousPath = Environment.GetEnvironmentVariable("PATH");
try
{
Environment.SetEnvironmentVariable("PATH", "");

Assert.Null(RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
}
finally
{
Environment.SetEnvironmentVariable("PATH", previousPath);
}
}

[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
public void DotNetInPath_Symlinked()
{
var previousPath = Environment.GetEnvironmentVariable("PATH");
try
{
using var tempRoot = new TempRoot();
var testDir = tempRoot.CreateDirectory();
var globalDotNetDir = testDir.CreateDirectory("global-dotnet");
var globalDotNetExe = globalDotNetDir.CreateFile($"dotnet{PlatformInformation.ExeExtension}");
var binDir = testDir.CreateDirectory("bin");
var symlinkPath = Path.Combine(binDir.Path, $"dotnet{PlatformInformation.ExeExtension}");

// Create symlink from binDir to the actual dotnet executable
File.CreateSymbolicLink(path: symlinkPath, pathToTarget: globalDotNetExe.Path);

Environment.SetEnvironmentVariable("PATH", binDir.Path);

Assert.Equal(globalDotNetDir.Path, RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
}
finally
{
Environment.SetEnvironmentVariable("PATH", previousPath);
}
}
}

#if !NET
file static class NativeMethods
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would convey intent more clearly, to wrap this in #if !NET.

Copy link
Member Author

@jjonescz jjonescz Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that was my intention, it just got lost in some code move. I guess the BCL method just takes precedence on NET, hence this works even now.

{
extension(File)
{
/// <remarks>
/// Only used by tests currently (might need some hardening if this is to be used by production code).
/// </remarks>
public static void CreateSymbolicLink(string path, string pathToTarget)
{
bool ok = CreateSymbolicLink(
lpSymlinkFileName: path,
lpTargetFileName: pathToTarget,
dwFlags: SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE);
if (!ok)
{
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
}
}
}

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CreateSymbolicLink(
string lpSymlinkFileName,
string lpTargetFileName,
uint dwFlags);

private const uint SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x2;
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ public void GetServerEnvironmentVariables_IncludesDotNetRoot()

var envVars = BuildServerConnection.GetServerEnvironmentVariables(currentEnvironment);

if (RuntimeHostInfo.GetToolDotNetRoot() is { } dotNetRoot)
if (RuntimeHostInfo.GetToolDotNetRoot(Logger.Log) is { } dotNetRoot)
{
// Should have environment variables including DOTNET_ROOT
Assert.NotNull(envVars);
Expand Down
2 changes: 1 addition & 1 deletion src/Compilers/Shared/BuildServerConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ private static IntPtr CreateEnvironmentBlock(Dictionary<string, string> environm
/// <returns>Dictionary of environment variables to set, or null if no custom environment is needed</returns>
internal static Dictionary<string, string>? GetServerEnvironmentVariables(System.Collections.IDictionary currentEnvironment, ICompilerServerLogger? logger = null)
{
if (RuntimeHostInfo.GetToolDotNetRoot() is not { } dotNetRoot)
if (RuntimeHostInfo.GetToolDotNetRoot(logger is null ? null : logger.Log) is not { } dotNetRoot)
{
return null;
}
Expand Down
82 changes: 82 additions & 0 deletions src/Compilers/Shared/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
#nullable enable

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Win32.SafeHandles;

namespace Microsoft.CodeAnalysis.CommandLine
{
Expand Down Expand Up @@ -93,5 +95,85 @@ out PROCESS_INFORMATION lpProcessInformation

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern IntPtr GetCommandLine();

#if !NET
//------------------------------------------------------------------------------
// ResolveLinkTarget
//------------------------------------------------------------------------------
extension(File)
{
public static FileSystemInfo? ResolveLinkTarget(string path, bool returnFinalTarget)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work on Linux/Mac?

Copy link
Member Author

@jjonescz jjonescz Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but on Linux, we run on .NET Core where the File.ResolveLinkTarget API exists from the BCL. This custom Win32 polyfill is defined only for .NET Framework (which I think we only support on Windows? I'm not sure about Mono, but given the other NativeMethods are also win32 APIs, this seems fine).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. I think I'm following. These are netframework only. If these are ever used, we expect the caller is running on Windows. I missed the #if !NET above.

{
if (!returnFinalTarget) throw new NotSupportedException();

using var handle = CreateFileW(
lpFileName: path,
dwDesiredAccess: FILE_READ_ATTRIBUTES,
dwShareMode: FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
lpSecurityAttributes: IntPtr.Zero,
dwCreationDisposition: OPEN_EXISTING,
dwFlagsAndAttributes: FILE_FLAG_BACKUP_SEMANTICS, // needed for directories
hTemplateFile: IntPtr.Zero);

if (handle.IsInvalid)
{
return null;
}

uint flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
uint needed = GetFinalPathNameByHandleW(hFile: handle, lpszFilePath: null, cchFilePath: 0, dwFlags: flags);
if (needed == 0) return null;

var sb = new StringBuilder((int)needed + 1);
uint len = GetFinalPathNameByHandleW(hFile: handle, lpszFilePath: sb, cchFilePath: (uint)sb.Capacity, dwFlags: flags);
if (len == 0) return null;

return new FileInfo(TrimWin32ExtendedPrefix(sb.ToString()));
}
}

private static string TrimWin32ExtendedPrefix(string s)
{
if (s.StartsWith(@"\\?\UNC\", StringComparison.Ordinal))
return @"\\" + s.Substring(8);
if (s.StartsWith(@"\\?\", StringComparison.Ordinal))
return s.Substring(4);
return s;
}

// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants
private const uint FILE_READ_ATTRIBUTES = 0x0080;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do these values come from? What documentation can I look at to verify them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend linking liberally to the docs for these constants and functions in the comments.


// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
private const uint FILE_SHARE_READ = 0x00000001;
private const uint FILE_SHARE_WRITE = 0x00000002;
private const uint FILE_SHARE_DELETE = 0x00000004;
private const uint OPEN_EXISTING = 3;
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew
private const uint VOLUME_NAME_DOS = 0x0;
private const uint FILE_NAME_NORMALIZED = 0x0;

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle hFile,
StringBuilder? lpszFilePath,
uint cchFilePath,
uint dwFlags);
#endif

}
}
39 changes: 24 additions & 15 deletions src/Compilers/Shared/RuntimeHostInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using System;
using System.IO;
using Microsoft.CodeAnalysis.CommandLine;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis
Expand All @@ -32,29 +33,32 @@ internal static class RuntimeHostInfo
/// <summary>
/// The <c>DOTNET_ROOT</c> that should be used when launching executable tools.
/// </summary>
internal static string? GetToolDotNetRoot()
internal static string? GetToolDotNetRoot(Action<string, object[]>? logger)
{
if (GetDotNetHostPath() is { } dotNetHostPath)
var dotNetPath = GetDotNetPathOrDefault();

// Resolve symlinks to dotnet
try
{
return Path.GetDirectoryName(dotNetHostPath);
var resolvedPath = File.ResolveLinkTarget(dotNetPath, returnFinalTarget: true);
if (resolvedPath != null)
{
dotNetPath = resolvedPath.FullName;
}
}

return null;
}

private static string? GetDotNetHostPath()
{
if (Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName) is { Length: > 0 } pathToDotNet)
catch (Exception ex)
{
return pathToDotNet;
logger?.Invoke("Failed to resolve symbolic link for dotnet path '{0}': {1}", [dotNetPath, ex.Message]);
return null;
}

if (Environment.GetEnvironmentVariable(DotNetExperimentalHostPathEnvironmentName) is { Length: > 0 } pathToDotNetExperimental)
var directoryName = Path.GetDirectoryName(dotNetPath);
if (string.IsNullOrEmpty(directoryName))
{
return pathToDotNetExperimental;
return null;
}

return null;
return directoryName;
}

/// <summary>
Expand All @@ -64,11 +68,16 @@ internal static class RuntimeHostInfo
/// </summary>
internal static string GetDotNetPathOrDefault()
{
if (GetDotNetHostPath() is { } pathToDotNet)
if (Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName) is { Length: > 0 } pathToDotNet)
{
return pathToDotNet;
}

if (Environment.GetEnvironmentVariable(DotNetExperimentalHostPathEnvironmentName) is { Length: > 0 } pathToDotNetExperimental)
{
return pathToDotNetExperimental;
}

var (fileName, sep) = PlatformInformation.IsWindows
? ("dotnet.exe", new char[] { ';' })
: ("dotnet", new char[] { ':' });
Expand Down
Loading