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/3] 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/3] 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/3] 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)) {