diff --git a/documentation/wiki/ChangeWaves.md b/documentation/wiki/ChangeWaves.md index a6fd5b23f0f..0503718715a 100644 --- a/documentation/wiki/ChangeWaves.md +++ b/documentation/wiki/ChangeWaves.md @@ -30,6 +30,7 @@ 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. - [RAR task: across multiple input properties, resolve relative paths against the project directory (not the process current directory)](https://github.com/dotnet/msbuild/pull/13319) - [Console, parallel console, and terminal loggers print the paths of log files written by registered loggers (e.g. file logger and binary logger) as part of the end-of-build summary.](https://github.com/dotnet/msbuild/pull/13577) diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs index 7a12d44ea2c..a89eef4add8 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() { @@ -1915,6 +1943,113 @@ 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 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() + { + 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 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 f726294145e..9f27e46d13c 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] || @@ -2225,7 +2229,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]); @@ -3239,6 +3247,104 @@ 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 (!ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_8) + || NativeMethodsShared.IsWindows + || string.IsNullOrEmpty(projectFile) + || Path.IsPathRooted(projectFile)) + { + return projectFile; + } + + string logicalCurrentDirectory = Environment.GetEnvironmentVariable("PWD"); + if (string.IsNullOrEmpty(logicalCurrentDirectory) || !Path.IsPathRooted(logicalCurrentDirectory)) + { + return projectFile; + } + + string currentDirectory = Directory.GetCurrentDirectory(); + 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("..")) + { + 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); + } + } + private static void ValidateExtensions(string[] projectExtensionsToIgnore) { if (projectExtensionsToIgnore?.Length > 0)