Skip to content

Commit 18e5d75

Browse files
authored
Consider PATH again when searching for dotnet host (#80859)
Fixes dotnet/msbuild#12669. Before the change to use csc apphost, we were searching for `dotnet.exe` either from `DOTNET_HOST_PATH` or `PATH`. With csc apphost (#80026) we regressed this by considering the dotnet host only from `DOTNET_HOST_PATH` (and passing that as `DOTNET_ROOT` to the apphost) - but that variable is not passed in MSBuilds older than 18.x (where the previous non-apphost implementation would work fine since it would fallback to finding `dotnet.exe` in `PATH`).
1 parent 9649d3b commit 18e5d75

File tree

6 files changed

+221
-18
lines changed

6 files changed

+221
-18
lines changed

src/Compilers/Core/MSBuildTask/ManagedToolTask.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ protected override bool ValidateParameters()
241241
{
242242
// Set DOTNET_ROOT so that the apphost executables launch properly.
243243
// Unset all other DOTNET_ROOT* variables so for example DOTNET_ROOT_X64 does not override ours.
244-
if (RuntimeHostInfo.GetToolDotNetRoot() is { } dotNetRoot)
244+
if (RuntimeHostInfo.GetToolDotNetRoot(Log.LogMessage) is { } dotNetRoot)
245245
{
246246
Log.LogMessage("Setting {0} to '{1}'", RuntimeHostInfo.DotNetRootEnvironmentName, dotNetRoot);
247247
EnvironmentVariables =
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.IO;
7+
using System.Runtime.InteropServices;
8+
using Microsoft.CodeAnalysis.Test.Utilities;
9+
using Roslyn.Test.Utilities;
10+
using Roslyn.Utilities;
11+
using Xunit;
12+
using Xunit.Abstractions;
13+
14+
namespace Microsoft.CodeAnalysis.BuildTasks.UnitTests;
15+
16+
public sealed class RuntimeHostInfoTests(ITestOutputHelper output)
17+
{
18+
private readonly ITestOutputHelper _output = output;
19+
20+
[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
21+
public void DotNetInPath()
22+
{
23+
var previousPath = Environment.GetEnvironmentVariable("PATH");
24+
try
25+
{
26+
using var tempRoot = new TempRoot();
27+
var testDir = tempRoot.CreateDirectory();
28+
var globalDotNetDir = testDir.CreateDirectory("global-dotnet");
29+
var globalDotNetExe = globalDotNetDir.CreateFile($"dotnet{PlatformInformation.ExeExtension}");
30+
Environment.SetEnvironmentVariable("PATH", globalDotNetDir.Path);
31+
32+
Assert.Equal(globalDotNetDir.Path, RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
33+
}
34+
finally
35+
{
36+
Environment.SetEnvironmentVariable("PATH", previousPath);
37+
}
38+
}
39+
40+
[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
41+
public void DotNetInPath_None()
42+
{
43+
var previousPath = Environment.GetEnvironmentVariable("PATH");
44+
try
45+
{
46+
Environment.SetEnvironmentVariable("PATH", "");
47+
48+
Assert.Null(RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
49+
}
50+
finally
51+
{
52+
Environment.SetEnvironmentVariable("PATH", previousPath);
53+
}
54+
}
55+
56+
[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
57+
public void DotNetInPath_Symlinked()
58+
{
59+
var previousPath = Environment.GetEnvironmentVariable("PATH");
60+
try
61+
{
62+
using var tempRoot = new TempRoot();
63+
var testDir = tempRoot.CreateDirectory();
64+
var globalDotNetDir = testDir.CreateDirectory("global-dotnet");
65+
var globalDotNetExe = globalDotNetDir.CreateFile($"dotnet{PlatformInformation.ExeExtension}");
66+
var binDir = testDir.CreateDirectory("bin");
67+
var symlinkPath = Path.Combine(binDir.Path, $"dotnet{PlatformInformation.ExeExtension}");
68+
69+
// Create symlink from binDir to the actual dotnet executable
70+
File.CreateSymbolicLink(path: symlinkPath, pathToTarget: globalDotNetExe.Path);
71+
72+
Environment.SetEnvironmentVariable("PATH", binDir.Path);
73+
74+
Assert.Equal(globalDotNetDir.Path, RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
75+
}
76+
finally
77+
{
78+
Environment.SetEnvironmentVariable("PATH", previousPath);
79+
}
80+
}
81+
}
82+
83+
#if !NET
84+
file static class NativeMethods
85+
{
86+
extension(File)
87+
{
88+
/// <remarks>
89+
/// Only used by tests currently (might need some hardening if this is to be used by production code).
90+
/// </remarks>
91+
public static void CreateSymbolicLink(string path, string pathToTarget)
92+
{
93+
bool ok = CreateSymbolicLink(
94+
lpSymlinkFileName: path,
95+
lpTargetFileName: pathToTarget,
96+
dwFlags: SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE);
97+
if (!ok)
98+
{
99+
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
100+
}
101+
}
102+
}
103+
104+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
105+
private static extern bool CreateSymbolicLink(
106+
string lpSymlinkFileName,
107+
string lpTargetFileName,
108+
uint dwFlags);
109+
110+
private const uint SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x2;
111+
}
112+
#endif

src/Compilers/Server/VBCSCompilerTests/BuildServerConnectionTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public void GetServerEnvironmentVariables_IncludesDotNetRoot()
149149

150150
var envVars = BuildServerConnection.GetServerEnvironmentVariables(currentEnvironment);
151151

152-
if (RuntimeHostInfo.GetToolDotNetRoot() is { } dotNetRoot)
152+
if (RuntimeHostInfo.GetToolDotNetRoot(Logger.Log) is { } dotNetRoot)
153153
{
154154
// Should have environment variables including DOTNET_ROOT
155155
Assert.NotNull(envVars);

src/Compilers/Shared/BuildServerConnection.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ private static IntPtr CreateEnvironmentBlock(Dictionary<string, string> environm
483483
/// <returns>Dictionary of environment variables to set, or null if no custom environment is needed</returns>
484484
internal static Dictionary<string, string>? GetServerEnvironmentVariables(System.Collections.IDictionary currentEnvironment, ICompilerServerLogger? logger = null)
485485
{
486-
if (RuntimeHostInfo.GetToolDotNetRoot() is not { } dotNetRoot)
486+
if (RuntimeHostInfo.GetToolDotNetRoot(logger is null ? null : logger.Log) is not { } dotNetRoot)
487487
{
488488
return null;
489489
}

src/Compilers/Shared/NativeMethods.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
#nullable enable
66

77
using System;
8+
using System.IO;
89
using System.Runtime.InteropServices;
910
using System.Runtime.Versioning;
1011
using System.Text;
12+
using Microsoft.Win32.SafeHandles;
1113

1214
namespace Microsoft.CodeAnalysis.CommandLine
1315
{
@@ -93,5 +95,85 @@ out PROCESS_INFORMATION lpProcessInformation
9395

9496
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
9597
internal static extern IntPtr GetCommandLine();
98+
99+
#if !NET
100+
//------------------------------------------------------------------------------
101+
// ResolveLinkTarget
102+
//------------------------------------------------------------------------------
103+
extension(File)
104+
{
105+
public static FileSystemInfo? ResolveLinkTarget(string path, bool returnFinalTarget)
106+
{
107+
if (!returnFinalTarget) throw new NotSupportedException();
108+
109+
using var handle = CreateFileW(
110+
lpFileName: path,
111+
dwDesiredAccess: FILE_READ_ATTRIBUTES,
112+
dwShareMode: FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
113+
lpSecurityAttributes: IntPtr.Zero,
114+
dwCreationDisposition: OPEN_EXISTING,
115+
dwFlagsAndAttributes: FILE_FLAG_BACKUP_SEMANTICS, // needed for directories
116+
hTemplateFile: IntPtr.Zero);
117+
118+
if (handle.IsInvalid)
119+
{
120+
return null;
121+
}
122+
123+
uint flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
124+
uint needed = GetFinalPathNameByHandleW(hFile: handle, lpszFilePath: null, cchFilePath: 0, dwFlags: flags);
125+
if (needed == 0) return null;
126+
127+
var sb = new StringBuilder((int)needed + 1);
128+
uint len = GetFinalPathNameByHandleW(hFile: handle, lpszFilePath: sb, cchFilePath: (uint)sb.Capacity, dwFlags: flags);
129+
if (len == 0) return null;
130+
131+
return new FileInfo(TrimWin32ExtendedPrefix(sb.ToString()));
132+
}
133+
}
134+
135+
private static string TrimWin32ExtendedPrefix(string s)
136+
{
137+
if (s.StartsWith(@"\\?\UNC\", StringComparison.Ordinal))
138+
return @"\\" + s.Substring(8);
139+
if (s.StartsWith(@"\\?\", StringComparison.Ordinal))
140+
return s.Substring(4);
141+
return s;
142+
}
143+
144+
// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants
145+
private const uint FILE_READ_ATTRIBUTES = 0x0080;
146+
147+
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
148+
private const uint FILE_SHARE_READ = 0x00000001;
149+
private const uint FILE_SHARE_WRITE = 0x00000002;
150+
private const uint FILE_SHARE_DELETE = 0x00000004;
151+
private const uint OPEN_EXISTING = 3;
152+
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
153+
154+
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew
155+
private const uint VOLUME_NAME_DOS = 0x0;
156+
private const uint FILE_NAME_NORMALIZED = 0x0;
157+
158+
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
159+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
160+
private static extern SafeFileHandle CreateFileW(
161+
string lpFileName,
162+
uint dwDesiredAccess,
163+
uint dwShareMode,
164+
IntPtr lpSecurityAttributes,
165+
uint dwCreationDisposition,
166+
uint dwFlagsAndAttributes,
167+
IntPtr hTemplateFile);
168+
169+
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew
170+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
171+
private static extern uint GetFinalPathNameByHandleW(
172+
SafeFileHandle hFile,
173+
StringBuilder? lpszFilePath,
174+
uint cchFilePath,
175+
uint dwFlags);
176+
#endif
177+
96178
}
97179
}

src/Compilers/Shared/RuntimeHostInfo.cs

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
using System;
88
using System.IO;
9+
using Microsoft.CodeAnalysis.CommandLine;
910
using Roslyn.Utilities;
1011

1112
namespace Microsoft.CodeAnalysis
@@ -32,29 +33,32 @@ internal static class RuntimeHostInfo
3233
/// <summary>
3334
/// The <c>DOTNET_ROOT</c> that should be used when launching executable tools.
3435
/// </summary>
35-
internal static string? GetToolDotNetRoot()
36+
internal static string? GetToolDotNetRoot(Action<string, object[]>? logger)
3637
{
37-
if (GetDotNetHostPath() is { } dotNetHostPath)
38+
var dotNetPath = GetDotNetPathOrDefault();
39+
40+
// Resolve symlinks to dotnet
41+
try
3842
{
39-
return Path.GetDirectoryName(dotNetHostPath);
43+
var resolvedPath = File.ResolveLinkTarget(dotNetPath, returnFinalTarget: true);
44+
if (resolvedPath != null)
45+
{
46+
dotNetPath = resolvedPath.FullName;
47+
}
4048
}
41-
42-
return null;
43-
}
44-
45-
private static string? GetDotNetHostPath()
46-
{
47-
if (Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName) is { Length: > 0 } pathToDotNet)
49+
catch (Exception ex)
4850
{
49-
return pathToDotNet;
51+
logger?.Invoke("Failed to resolve symbolic link for dotnet path '{0}': {1}", [dotNetPath, ex.Message]);
52+
return null;
5053
}
5154

52-
if (Environment.GetEnvironmentVariable(DotNetExperimentalHostPathEnvironmentName) is { Length: > 0 } pathToDotNetExperimental)
55+
var directoryName = Path.GetDirectoryName(dotNetPath);
56+
if (string.IsNullOrEmpty(directoryName))
5357
{
54-
return pathToDotNetExperimental;
58+
return null;
5559
}
5660

57-
return null;
61+
return directoryName;
5862
}
5963

6064
/// <summary>
@@ -64,11 +68,16 @@ internal static class RuntimeHostInfo
6468
/// </summary>
6569
internal static string GetDotNetPathOrDefault()
6670
{
67-
if (GetDotNetHostPath() is { } pathToDotNet)
71+
if (Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName) is { Length: > 0 } pathToDotNet)
6872
{
6973
return pathToDotNet;
7074
}
7175

76+
if (Environment.GetEnvironmentVariable(DotNetExperimentalHostPathEnvironmentName) is { Length: > 0 } pathToDotNetExperimental)
77+
{
78+
return pathToDotNetExperimental;
79+
}
80+
7281
var (fileName, sep) = PlatformInformation.IsWindows
7382
? ("dotnet.exe", new char[] { ';' })
7483
: ("dotnet", new char[] { ':' });

0 commit comments

Comments
 (0)