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/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 7a12d44ea2c..31f32f7f968 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,48 @@ 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); + } + /// /// 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 f726294145e..c6dbdb682b1 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,50 @@ internal static string ProcessProjectSwitch( return projectFile; } + /// + /// 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, 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) + || 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; + } + + string logicalCurrentDirectory = Environment.GetEnvironmentVariable("PWD"); + if (string.IsNullOrEmpty(logicalCurrentDirectory) || !Path.IsPathRooted(logicalCurrentDirectory)) + { + return projectFile; + } + + // 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; + } + + return Path.GetFullPath(Path.Combine(logicalCurrentDirectory, projectFile)); + } + private static void ValidateExtensions(string[] projectExtensionsToIgnore) { if (projectExtensionsToIgnore?.Length > 0)