From fa2f8d72a986187fba6c8cfd80a7286a749dbd9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Wed, 13 May 2026 15:11:50 +0200 Subject: [PATCH 1/4] producing same paths for the same project for both absolute and relative paths --- src/MSBuild.UnitTests/XMake_Tests.cs | 35 +++++++++++++++++++ src/MSBuild/XMake.cs | 52 ++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs index 733ce223893..60a2c9e21a5 100644 --- a/src/MSBuild.UnitTests/XMake_Tests.cs +++ b/src/MSBuild.UnitTests/XMake_Tests.cs @@ -1873,6 +1873,41 @@ public void TestProcessProjectSwitchReplicateBuildingDFLKG() MSBuildApp.ProcessProjectSwitch(Array.Empty(), extensionsToIgnore, projectHelper.GetFiles).ShouldBe("test.proj"); // "Expected test.proj to be only project found" } + [UnixOnlyFact] + public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSymlinkFromPwd() + { + string root = _env.CreateFolder().Path; + string realDirectory = Path.Combine(root, "real"); + string linkDirectory = Path.Combine(root, "link"); + Directory.CreateDirectory(realDirectory); + + string errorMessage = null; + NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage); + + _env.SetCurrentDirectory(linkDirectory); + _env.SetEnvironmentVariable("PWD", linkDirectory); + + string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj"); + + MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) + .ShouldBe(Path.Combine(linkDirectory, relativeProjectPath)); + } + + [UnixOnlyFact] + public void ResolveProjectPathAgainstLogicalCurrentDirectoryIgnoresMismatchedPwd() + { + string currentDirectory = _env.CreateFolder().Path; + string otherDirectory = _env.CreateFolder().Path; + + _env.SetCurrentDirectory(currentDirectory); + _env.SetEnvironmentVariable("PWD", otherDirectory); + + string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj"); + + MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) + .ShouldBe(relativeProjectPath); + } + /// /// Test the case where we remove all of the project extensions that exist in the directory /// diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs index 4fdb1aecdb3..170125b8374 100644 --- a/src/MSBuild/XMake.cs +++ b/src/MSBuild/XMake.cs @@ -361,7 +361,11 @@ private static bool CanRunServerBasedOnCommandLineSwitches(string[] commandLine) { commandLineSwitches = CombineSwitchesRespectingPriority(switchesFromAutoResponseFile, switchesNotFromAutoResponseFile, fullCommandLine); } - string projectFile = ProcessProjectSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project], commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions], Directory.GetFiles); + string projectFile = ResolveProjectPathAgainstLogicalCurrentDirectory( + ProcessProjectSwitch( + commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project], + commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions], + Directory.GetFiles)); if (commandLineSwitches[CommandLineSwitches.ParameterlessSwitch.Help] || commandLineSwitches.IsParameterizedSwitchSet(CommandLineSwitches.ParameterizedSwitch.NodeMode) || commandLineSwitches[CommandLineSwitches.ParameterlessSwitch.Version] || @@ -2220,7 +2224,11 @@ private static bool ProcessCommandLineSwitches( commandLine); } - projectFile = ProcessProjectSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project], commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions], Directory.GetFiles); + projectFile = ResolveProjectPathAgainstLogicalCurrentDirectory( + ProcessProjectSwitch( + commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project], + commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions], + Directory.GetFiles)); // figure out which targets we are building targets = ProcessTargetSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Target]); @@ -3234,6 +3242,46 @@ internal static string ProcessProjectSwitch( return projectFile; } + internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string projectFile) + { + if (NativeMethodsShared.IsWindows || Path.IsPathRooted(projectFile)) + { + return projectFile; + } + + string logicalCurrentDirectory = Environment.GetEnvironmentVariable("PWD"); + if (string.IsNullOrEmpty(logicalCurrentDirectory) || !Path.IsPathRooted(logicalCurrentDirectory)) + { + return projectFile; + } + + string currentDirectory = Directory.GetCurrentDirectory(); + if (!IsSamePhysicalDirectoryAsCurrentDirectory(logicalCurrentDirectory, currentDirectory)) + { + return projectFile; + } + + return Path.Combine(logicalCurrentDirectory, projectFile); + } + + private static bool IsSamePhysicalDirectoryAsCurrentDirectory(string directory, string currentDirectory) + { + string savedCurrentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(directory); + return string.Equals(Directory.GetCurrentDirectory(), currentDirectory, StringComparison.Ordinal); + } + catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex)) + { + return false; + } + finally + { + Directory.SetCurrentDirectory(savedCurrentDirectory); + } + } + private static void ValidateExtensions(string[] projectExtensionsToIgnore) { if (projectExtensionsToIgnore?.Length > 0) From 1cd105528c91204fbd810be22528dea4ab39ed60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Fri, 15 May 2026 13:28:42 +0200 Subject: [PATCH 2/4] review request locally addressed comments --- src/MSBuild.UnitTests/XMake_Tests.cs | 53 ++++++++++++++++++++ src/MSBuild/CommandLine/CommandLineParser.cs | 6 ++- src/MSBuild/XMake.cs | 7 ++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs index 60a2c9e21a5..39d190587d7 100644 --- a/src/MSBuild.UnitTests/XMake_Tests.cs +++ b/src/MSBuild.UnitTests/XMake_Tests.cs @@ -1362,6 +1362,34 @@ public void ResponseFileInProjectDirectoryFoundImplicitly() output.ShouldContain("[A=1]"); } + [UnixOnlyFact] + public void ResponseFileInLogicalProjectDirectoryFoundImplicitly() + { + string root = _env.CreateFolder().Path; + string realDirectory = Path.Combine(root, "repo", "project"); + string logicalParentDirectory = Path.Combine(root, "links"); + string linkDirectory = Path.Combine(logicalParentDirectory, "project"); + Directory.CreateDirectory(realDirectory); + Directory.CreateDirectory(logicalParentDirectory); + + string errorMessage = null; + NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage); + + string content = ObjectModelHelpers.CleanupFileContents(""); + File.WriteAllText(Path.Combine(realDirectory, "my.proj"), content); + File.WriteAllText(Path.Combine(root, "repo", "Directory.Build.rsp"), "/p:A=physical"); + File.WriteAllText(Path.Combine(logicalParentDirectory, "Directory.Build.rsp"), "/p:A=logical"); + + _env.SetCurrentDirectory(linkDirectory); + _env.SetEnvironmentVariable("PWD", linkDirectory); + + string output = RunnerUtilities.ExecMSBuild("my.proj", out var successfulExit, _output); + successfulExit.ShouldBeTrue(); + + output.ShouldContain("[A=logical]"); + output.ShouldNotContain("[A=physical]"); + } + [Fact] public void ResponseFileSwitchesAppearInCommandLine() { @@ -1893,6 +1921,31 @@ public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSymlinkFrom .ShouldBe(Path.Combine(linkDirectory, relativeProjectPath)); } + [UnixOnlyFact] + public void ResolveProjectPathAgainstLogicalCurrentDirectoryDoesNotRebaseToDifferentPhysicalPath() + { + string root = _env.CreateFolder().Path; + string realCurrentDirectory = Path.Combine(root, "repo", "current"); + string realProjectDirectory = Path.Combine(root, "repo", "other"); + string linkParentDirectory = Path.Combine(root, "links"); + string linkDirectory = Path.Combine(linkParentDirectory, "link"); + Directory.CreateDirectory(realCurrentDirectory); + Directory.CreateDirectory(realProjectDirectory); + Directory.CreateDirectory(linkParentDirectory); + File.WriteAllText(Path.Combine(realProjectDirectory, "MyApp.csproj"), ""); + + string errorMessage = null; + NativeMethodsShared.MakeSymbolicLink(linkDirectory, realCurrentDirectory, ref errorMessage).ShouldBeTrue(errorMessage); + + _env.SetCurrentDirectory(linkDirectory); + _env.SetEnvironmentVariable("PWD", linkDirectory); + + string relativeProjectPath = Path.Combine("..", "other", "MyApp.csproj"); + + MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) + .ShouldBe(relativeProjectPath); + } + [UnixOnlyFact] public void ResolveProjectPathAgainstLogicalCurrentDirectoryIgnoresMismatchedPwd() { diff --git a/src/MSBuild/CommandLine/CommandLineParser.cs b/src/MSBuild/CommandLine/CommandLineParser.cs index 8a5802501ac..25aa30337f9 100644 --- a/src/MSBuild/CommandLine/CommandLineParser.cs +++ b/src/MSBuild/CommandLine/CommandLineParser.cs @@ -550,7 +550,7 @@ private static string GetProjectDirectory(string[] projectSwitchParameters) if (projectSwitchParameters.Length == 1) { - var projectFile = FileUtilities.FixFilePath(projectSwitchParameters[0]); + var projectFile = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(FileUtilities.FixFilePath(projectSwitchParameters[0])); if (FileSystems.Default.DirectoryExists(projectFile)) { @@ -563,6 +563,10 @@ private static string GetProjectDirectory(string[] projectSwitchParameters) projectDirectory = Path.GetDirectoryName(Path.GetFullPath(projectFile)); } } + else + { + projectDirectory = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(projectDirectory); + } return projectDirectory; } diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs index 170125b8374..4aa411d3ed1 100644 --- a/src/MSBuild/XMake.cs +++ b/src/MSBuild/XMake.cs @@ -3244,7 +3244,7 @@ internal static string ProcessProjectSwitch( internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string projectFile) { - if (NativeMethodsShared.IsWindows || Path.IsPathRooted(projectFile)) + if (NativeMethodsShared.IsWindows || string.IsNullOrEmpty(projectFile) || Path.IsPathRooted(projectFile)) { return projectFile; } @@ -3261,6 +3261,11 @@ internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string p return projectFile; } + if (projectFile.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..")) + { + return projectFile; + } + return Path.Combine(logicalCurrentDirectory, projectFile); } From 5f4829a584ac6a04c14b41f31acdef93a7915ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Mon, 18 May 2026 10:54:20 +0200 Subject: [PATCH 3/4] changewave to not break builds --- documentation/wiki/ChangeWaves.md | 3 ++ src/Framework/ChangeWaves.cs | 3 +- src/MSBuild.UnitTests/XMake_Tests.cs | 47 +++++++++++++++++++ src/MSBuild/XMake.cs | 67 +++++++++++++++++++++++++--- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/documentation/wiki/ChangeWaves.md b/documentation/wiki/ChangeWaves.md index 9662b81665d..07a319ae917 100644 --- a/documentation/wiki/ChangeWaves.md +++ b/documentation/wiki/ChangeWaves.md @@ -29,6 +29,9 @@ Change wave checks around features will be removed in the release that accompani ## Current Rotation of Change Waves +### 18.8 +- Use the Unix logical current directory from `PWD` when resolving relative project paths, so builds under symlinked directories produce logical project full paths and related output paths. Set `MSBUILDDISABLEFEATURESFROMVERSION=18.8` to opt out. + ### 18.7 - [Copy task retries on ERROR_ACCESS_DENIED on non-Windows platforms to handle transient lock conflicts (e.g. macOS CoW filesystems)](https://github.com/dotnet/msbuild/issues/13463) - [Fix ASP.NET WebSite projects to resolve netstandard2.0 dependencies](https://github.com/dotnet/msbuild/pull/13058) - Pass TargetFrameworkVersion to RAR task and copy netstandard.dll facade for .NET Framework 4.7.1+ web projects. diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs index 52ab8929eeb..301873a3182 100644 --- a/src/Framework/ChangeWaves.cs +++ b/src/Framework/ChangeWaves.cs @@ -35,7 +35,8 @@ internal static class ChangeWaves internal static readonly Version Wave18_5 = new Version(18, 5); internal static readonly Version Wave18_6 = new Version(18, 6); internal static readonly Version Wave18_7 = new Version(18, 7); - internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5, Wave18_6, Wave18_7]; + internal static readonly Version Wave18_8 = new Version(18, 8); + internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5, Wave18_6, Wave18_7, Wave18_8]; /// /// Special value indicating that all features behind all Change Waves should be enabled. diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs index 39d190587d7..e0c50f4e463 100644 --- a/src/MSBuild.UnitTests/XMake_Tests.cs +++ b/src/MSBuild.UnitTests/XMake_Tests.cs @@ -1921,6 +1921,28 @@ public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSymlinkFrom .ShouldBe(Path.Combine(linkDirectory, relativeProjectPath)); } + [UnixOnlyFact] + public void ResolveProjectPathAgainstLogicalCurrentDirectoryCanBeDisabledByChangeWave() + { + string root = _env.CreateFolder().Path; + string realDirectory = Path.Combine(root, "real"); + string linkDirectory = Path.Combine(root, "link"); + Directory.CreateDirectory(realDirectory); + + string errorMessage = null; + NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage); + + _env.SetCurrentDirectory(linkDirectory); + _env.SetEnvironmentVariable("PWD", linkDirectory); + _env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_8.ToString()); + ChangeWaves.ResetStateForTests(); + + string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj"); + + MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) + .ShouldBe(relativeProjectPath); + } + [UnixOnlyFact] public void ResolveProjectPathAgainstLogicalCurrentDirectoryDoesNotRebaseToDifferentPhysicalPath() { @@ -1946,6 +1968,31 @@ public void ResolveProjectPathAgainstLogicalCurrentDirectoryDoesNotRebaseToDiffe .ShouldBe(relativeProjectPath); } + [UnixOnlyFact] + public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSiblingPathThroughSymlink() + { + string root = _env.CreateFolder().Path; + string realDirectory = Path.Combine(root, "real"); + string realAppDirectory = Path.Combine(realDirectory, "App"); + string realLibDirectory = Path.Combine(realDirectory, "Lib"); + string linkDirectory = Path.Combine(root, "link"); + Directory.CreateDirectory(realAppDirectory); + Directory.CreateDirectory(realLibDirectory); + File.WriteAllText(Path.Combine(realLibDirectory, "Lib.csproj"), ""); + + string errorMessage = null; + NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage); + + string logicalAppDirectory = Path.Combine(linkDirectory, "App"); + _env.SetCurrentDirectory(logicalAppDirectory); + _env.SetEnvironmentVariable("PWD", logicalAppDirectory); + + string relativeProjectPath = Path.Combine("..", "Lib", "Lib.csproj"); + + MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) + .ShouldBe(Path.Combine(linkDirectory, "Lib", "Lib.csproj")); + } + [UnixOnlyFact] public void ResolveProjectPathAgainstLogicalCurrentDirectoryIgnoresMismatchedPwd() { diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs index 4aa411d3ed1..0f57e22a97f 100644 --- a/src/MSBuild/XMake.cs +++ b/src/MSBuild/XMake.cs @@ -3242,9 +3242,26 @@ internal static string ProcessProjectSwitch( return projectFile; } + /// + /// On Unix, rebases a relative project path onto the shell's logical working directory ($PWD) + /// when $PWD physically resolves to the same directory as . + /// This makes MSBuild produce the same $(MSBuildProjectFullPath) (and therefore the same + /// intermediate/output paths) regardless of whether the user reached the project via an absolute path + /// through a symlink or via a relative path under a symlinked working directory. + /// + /// + /// No-op on Windows (where GetCurrentDirectory already returns the as-typed path), when + /// is absolute, when PWD is unset/relative, or when PWD's + /// physical target differs from getcwd() (defense against a stale or mismatched PWD). + /// Gated by so users can opt out via + /// MSBuildDisableFeaturesFromVersion if the change breaks an existing workflow. + /// internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string projectFile) { - if (NativeMethodsShared.IsWindows || string.IsNullOrEmpty(projectFile) || Path.IsPathRooted(projectFile)) + if (!ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_8) + || NativeMethodsShared.IsWindows + || string.IsNullOrEmpty(projectFile) + || Path.IsPathRooted(projectFile)) { return projectFile; } @@ -3256,26 +3273,62 @@ internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string p } string currentDirectory = Directory.GetCurrentDirectory(); - if (!IsSamePhysicalDirectoryAsCurrentDirectory(logicalCurrentDirectory, currentDirectory)) + if (!IsSamePhysicalDirectory(logicalCurrentDirectory, currentDirectory)) { return projectFile; } + string logicalProjectFile = Path.GetFullPath(Path.Combine(logicalCurrentDirectory, projectFile)); + + // A relative path without ".." segments can never lexically escape the shared physical prefix, + // so rebasing onto PWD is always safe. With ".." segments, lexical normalization may collapse + // the path above the prefix before any symlink resolution happens, so the logical and physical + // resolutions can land on different files. In that case, only rebase when both resolutions + // refer to the same physical target. if (projectFile.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..")) { - return projectFile; + string currentProjectFile = Path.GetFullPath(projectFile); + if (!IsSamePhysicalPath(logicalProjectFile, currentProjectFile)) + { + return projectFile; + } + } + + return logicalProjectFile; + } + + private static bool IsSamePhysicalPath(string firstPath, string secondPath) + { + if (FileSystems.Default.DirectoryExists(firstPath) && FileSystems.Default.DirectoryExists(secondPath)) + { + return IsSamePhysicalDirectory(firstPath, secondPath); + } + + if (FileSystems.Default.FileExists(firstPath) && FileSystems.Default.FileExists(secondPath)) + { + string firstDirectory = Path.GetDirectoryName(firstPath); + string secondDirectory = Path.GetDirectoryName(secondPath); + + return string.Equals(Path.GetFileName(firstPath), Path.GetFileName(secondPath), StringComparison.Ordinal) && + IsSamePhysicalDirectory(firstDirectory, secondDirectory); } - return Path.Combine(logicalCurrentDirectory, projectFile); + return false; } - private static bool IsSamePhysicalDirectoryAsCurrentDirectory(string directory, string currentDirectory) + // Compares two directories for physical-path equivalence by canonicalizing each through getcwd(). + // .NET does not expose POSIX realpath(3), and Path.GetFullPath only normalizes lexically (does not + // resolve symlinks). Mutating the process cwd is safe here because callers run on the single-threaded + // command-line parsing path before any build worker threads or child processes are started. + private static bool IsSamePhysicalDirectory(string firstDirectory, string secondDirectory) { string savedCurrentDirectory = Directory.GetCurrentDirectory(); try { - Directory.SetCurrentDirectory(directory); - return string.Equals(Directory.GetCurrentDirectory(), currentDirectory, StringComparison.Ordinal); + Directory.SetCurrentDirectory(firstDirectory); + string resolvedFirstDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(secondDirectory); + return string.Equals(Directory.GetCurrentDirectory(), resolvedFirstDirectory, StringComparison.Ordinal); } catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex)) { From 83f6ed86b226a949789c30b90227e56f90845cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Tue, 26 May 2026 09:46:48 +0200 Subject: [PATCH 4/4] cleaner fix --- src/Framework/NativeMethods.cs | 43 +++++++++ src/MSBuild.UnitTests/XMake_Tests.cs | 65 -------------- src/MSBuild/CommandLine/CommandLineParser.cs | 6 +- src/MSBuild/XMake.cs | 92 ++++---------------- 4 files changed, 63 insertions(+), 143 deletions(-) diff --git a/src/Framework/NativeMethods.cs b/src/Framework/NativeMethods.cs index 915cf0673e4..1fb1c20bbeb 100644 --- a/src/Framework/NativeMethods.cs +++ b/src/Framework/NativeMethods.cs @@ -1325,6 +1325,49 @@ internal static void RestoreConsoleMode(uint? originalConsoleMode, bool useStand [DllImport("libc", SetLastError = true)] internal static extern int symlink(string oldpath, string newpath); + [DllImport("libc", EntryPoint = "realpath", SetLastError = true)] + private static extern IntPtr realpath_native(string path, IntPtr resolved); + + [DllImport("libc", EntryPoint = "free")] + private static extern void libc_free(IntPtr ptr); + + /// + /// Resolves to its canonical form via POSIX realpath(3), following + /// symlinks. Returns null on Windows, on null/empty input, or when the call fails. + /// Unlike , this resolves symlinks against the real + /// filesystem without mutating process-global state, so it is safe to call from any thread. + /// + internal static string RealPath(string path) + { + if (IsWindows || string.IsNullOrEmpty(path)) + { + return null; + } + + IntPtr ptr = IntPtr.Zero; + try + { + ptr = realpath_native(path, IntPtr.Zero); + if (ptr == IntPtr.Zero) + { + return null; + } +#if NET + return Marshal.PtrToStringUTF8(ptr); +#else + return Marshal.PtrToStringAnsi(ptr); +#endif + } + finally + { + if (ptr != IntPtr.Zero) + { + // realpath() with NULL second arg returns a malloc()'d buffer; caller must free(). + libc_free(ptr); + } + } + } + #if FEATURE_WINDOWSINTEROP [SupportedOSPlatform("windows6.1")] internal static unsafe bool SetThreadErrorMode(int newMode, out int oldMode) diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs index a89eef4add8..31f32f7f968 100644 --- a/src/MSBuild.UnitTests/XMake_Tests.cs +++ b/src/MSBuild.UnitTests/XMake_Tests.cs @@ -1985,71 +1985,6 @@ public void ResolveProjectPathAgainstLogicalCurrentDirectoryCanBeDisabledByChang .ShouldBe(relativeProjectPath); } - [UnixOnlyFact] - public void ResolveProjectPathAgainstLogicalCurrentDirectoryDoesNotRebaseToDifferentPhysicalPath() - { - string root = _env.CreateFolder().Path; - string realCurrentDirectory = Path.Combine(root, "repo", "current"); - string realProjectDirectory = Path.Combine(root, "repo", "other"); - string linkParentDirectory = Path.Combine(root, "links"); - string linkDirectory = Path.Combine(linkParentDirectory, "link"); - Directory.CreateDirectory(realCurrentDirectory); - Directory.CreateDirectory(realProjectDirectory); - Directory.CreateDirectory(linkParentDirectory); - File.WriteAllText(Path.Combine(realProjectDirectory, "MyApp.csproj"), ""); - - string errorMessage = null; - NativeMethodsShared.MakeSymbolicLink(linkDirectory, realCurrentDirectory, ref errorMessage).ShouldBeTrue(errorMessage); - - _env.SetCurrentDirectory(linkDirectory); - _env.SetEnvironmentVariable("PWD", linkDirectory); - - string relativeProjectPath = Path.Combine("..", "other", "MyApp.csproj"); - - MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) - .ShouldBe(relativeProjectPath); - } - - [UnixOnlyFact] - public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSiblingPathThroughSymlink() - { - string root = _env.CreateFolder().Path; - string realDirectory = Path.Combine(root, "real"); - string realAppDirectory = Path.Combine(realDirectory, "App"); - string realLibDirectory = Path.Combine(realDirectory, "Lib"); - string linkDirectory = Path.Combine(root, "link"); - Directory.CreateDirectory(realAppDirectory); - Directory.CreateDirectory(realLibDirectory); - File.WriteAllText(Path.Combine(realLibDirectory, "Lib.csproj"), ""); - - string errorMessage = null; - NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage); - - string logicalAppDirectory = Path.Combine(linkDirectory, "App"); - _env.SetCurrentDirectory(logicalAppDirectory); - _env.SetEnvironmentVariable("PWD", logicalAppDirectory); - - string relativeProjectPath = Path.Combine("..", "Lib", "Lib.csproj"); - - MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) - .ShouldBe(Path.Combine(linkDirectory, "Lib", "Lib.csproj")); - } - - [UnixOnlyFact] - public void ResolveProjectPathAgainstLogicalCurrentDirectoryIgnoresMismatchedPwd() - { - string currentDirectory = _env.CreateFolder().Path; - string otherDirectory = _env.CreateFolder().Path; - - _env.SetCurrentDirectory(currentDirectory); - _env.SetEnvironmentVariable("PWD", otherDirectory); - - string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj"); - - MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath) - .ShouldBe(relativeProjectPath); - } - /// /// Test the case where we remove all of the project extensions that exist in the directory /// diff --git a/src/MSBuild/CommandLine/CommandLineParser.cs b/src/MSBuild/CommandLine/CommandLineParser.cs index 25aa30337f9..8a5802501ac 100644 --- a/src/MSBuild/CommandLine/CommandLineParser.cs +++ b/src/MSBuild/CommandLine/CommandLineParser.cs @@ -550,7 +550,7 @@ private static string GetProjectDirectory(string[] projectSwitchParameters) if (projectSwitchParameters.Length == 1) { - var projectFile = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(FileUtilities.FixFilePath(projectSwitchParameters[0])); + var projectFile = FileUtilities.FixFilePath(projectSwitchParameters[0]); if (FileSystems.Default.DirectoryExists(projectFile)) { @@ -563,10 +563,6 @@ private static string GetProjectDirectory(string[] projectSwitchParameters) projectDirectory = Path.GetDirectoryName(Path.GetFullPath(projectFile)); } } - else - { - projectDirectory = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(projectDirectory); - } return projectDirectory; } diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs index 9f27e46d13c..c6dbdb682b1 100644 --- a/src/MSBuild/XMake.cs +++ b/src/MSBuild/XMake.cs @@ -3248,26 +3248,28 @@ internal static string ProcessProjectSwitch( } /// - /// On Unix, rebases a relative project path onto the shell's logical working directory ($PWD) - /// when $PWD physically resolves to the same directory as . - /// This makes MSBuild produce the same $(MSBuildProjectFullPath) (and therefore the same - /// intermediate/output paths) regardless of whether the user reached the project via an absolute path - /// through a symlink or via a relative path under a symlinked working directory. + /// On Unix, rebases a relative project path onto $PWD when $PWD physically resolves + /// to the same directory as . This makes + /// $(MSBuildProjectFullPath) (and dependent output paths) stable whether the user reached + /// the project through a symlinked working directory or via the kernel-resolved absolute path. /// /// - /// No-op on Windows (where GetCurrentDirectory already returns the as-typed path), when - /// is absolute, when PWD is unset/relative, or when PWD's - /// physical target differs from getcwd() (defense against a stale or mismatched PWD). - /// Gated by so users can opt out via - /// MSBuildDisableFeaturesFromVersion if the change breaks an existing workflow. + /// No-op on Windows, when is absolute or contains .., + /// when PWD is unset/relative/stale, or when the change wave is disabled via + /// MSBuildDisableFeaturesFromVersion=18.8. Uses + /// (POSIX realpath(3)) so no process-global state is mutated; safe under MSBuild Server, + /// multithreaded MSBuild, and hosted scenarios. /// internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string projectFile) { if (!ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_8) || NativeMethodsShared.IsWindows || string.IsNullOrEmpty(projectFile) - || Path.IsPathRooted(projectFile)) + || Path.IsPathRooted(projectFile) + || projectFile.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..")) { + // Bail on ".." segments: lexical normalization can escape the shared physical prefix, + // so the logical and physical resolutions can end up pointing at different files. return projectFile; } @@ -3277,72 +3279,16 @@ internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string p return projectFile; } - string currentDirectory = Directory.GetCurrentDirectory(); - if (!IsSamePhysicalDirectory(logicalCurrentDirectory, currentDirectory)) + // Confirm $PWD physically resolves to the same directory as getcwd(); otherwise PWD is stale. + string physicalPwd = NativeMethodsShared.RealPath(logicalCurrentDirectory); + string physicalCwd = NativeMethodsShared.RealPath(Directory.GetCurrentDirectory()); + if (physicalPwd == null || physicalCwd == null + || !string.Equals(physicalPwd, physicalCwd, StringComparison.Ordinal)) { return projectFile; } - string logicalProjectFile = Path.GetFullPath(Path.Combine(logicalCurrentDirectory, projectFile)); - - // A relative path without ".." segments can never lexically escape the shared physical prefix, - // so rebasing onto PWD is always safe. With ".." segments, lexical normalization may collapse - // the path above the prefix before any symlink resolution happens, so the logical and physical - // resolutions can land on different files. In that case, only rebase when both resolutions - // refer to the same physical target. - if (projectFile.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..")) - { - string currentProjectFile = Path.GetFullPath(projectFile); - if (!IsSamePhysicalPath(logicalProjectFile, currentProjectFile)) - { - return projectFile; - } - } - - return logicalProjectFile; - } - - private static bool IsSamePhysicalPath(string firstPath, string secondPath) - { - if (FileSystems.Default.DirectoryExists(firstPath) && FileSystems.Default.DirectoryExists(secondPath)) - { - return IsSamePhysicalDirectory(firstPath, secondPath); - } - - if (FileSystems.Default.FileExists(firstPath) && FileSystems.Default.FileExists(secondPath)) - { - string firstDirectory = Path.GetDirectoryName(firstPath); - string secondDirectory = Path.GetDirectoryName(secondPath); - - return string.Equals(Path.GetFileName(firstPath), Path.GetFileName(secondPath), StringComparison.Ordinal) && - IsSamePhysicalDirectory(firstDirectory, secondDirectory); - } - - return false; - } - - // Compares two directories for physical-path equivalence by canonicalizing each through getcwd(). - // .NET does not expose POSIX realpath(3), and Path.GetFullPath only normalizes lexically (does not - // resolve symlinks). Mutating the process cwd is safe here because callers run on the single-threaded - // command-line parsing path before any build worker threads or child processes are started. - private static bool IsSamePhysicalDirectory(string firstDirectory, string secondDirectory) - { - string savedCurrentDirectory = Directory.GetCurrentDirectory(); - try - { - Directory.SetCurrentDirectory(firstDirectory); - string resolvedFirstDirectory = Directory.GetCurrentDirectory(); - Directory.SetCurrentDirectory(secondDirectory); - return string.Equals(Directory.GetCurrentDirectory(), resolvedFirstDirectory, StringComparison.Ordinal); - } - catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex)) - { - return false; - } - finally - { - Directory.SetCurrentDirectory(savedCurrentDirectory); - } + return Path.GetFullPath(Path.Combine(logicalCurrentDirectory, projectFile)); } private static void ValidateExtensions(string[] projectExtensionsToIgnore)