From 814ba2c68b08d2380c6696448d83966186d93f25 Mon Sep 17 00:00:00 2001 From: Azat Date: Mon, 3 Aug 2026 11:57:38 +0200 Subject: [PATCH] Fix OS-blind apphost probe in MTP bridge (regression from #16201) MtpServerConnection.BuildLaunch probed for a sibling apphost by swapping the source extension to .exe unconditionally and gating on File.Exists. On Unix the apphost is extension-less, so the .exe probe is wrong; worse, a payload built on Windows and run on Unix (e.g. aspnetcore tests built on a Windows agent and executed on a Linux Helix machine) drags a Windows PE Foo.exe next to the dll, which File.Exists happily finds and Process.Start then fails to launch with 'Permission denied' (or 'Exec format error' once +x). Make the probe OS-aware (extension-less apphost on Unix) and, on Unix, require an execute bit before treating the candidate as a usable apphost; otherwise fall back to 'dotnet '. Adds unit tests for BuildLaunch/IsUsableApphost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/MTP/MtpServerConnection.cs | 50 +++++- .../Client/MTP/MtpServerConnectionTests.cs | 146 ++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 test/Microsoft.TestPlatform.CrossPlatEngine.UnitTests/Client/MTP/MtpServerConnectionTests.cs diff --git a/src/Microsoft.TestPlatform.CrossPlatEngine/Client/MTP/MtpServerConnection.cs b/src/Microsoft.TestPlatform.CrossPlatEngine/Client/MTP/MtpServerConnection.cs index c4ac558b64..8f2d7a0781 100644 --- a/src/Microsoft.TestPlatform.CrossPlatEngine/Client/MTP/MtpServerConnection.cs +++ b/src/Microsoft.TestPlatform.CrossPlatEngine/Client/MTP/MtpServerConnection.cs @@ -8,6 +8,7 @@ using System.IO; using System.Net; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -396,7 +397,7 @@ private void FailPending(Exception exception) _pending.Clear(); } - private static (string fileName, string arguments, string workingDirectory) BuildLaunch(string source, int port) + internal static (string fileName, string arguments, string workingDirectory) BuildLaunch(string source, int port) { string serverArgs = $"{MtpConstants.ServerArgument} {MtpConstants.ClientPortArgument} {port} {MtpConstants.NoBannerArgument}"; string workingDirectory = Path.GetDirectoryName(source) ?? Directory.GetCurrentDirectory(); @@ -407,14 +408,55 @@ private static (string fileName, string arguments, string workingDirectory) Buil return (source, serverArgs, workingDirectory); } - // A .NET MTP app is typically shipped as a dll with a sibling apphost .exe. Prefer the apphost - // if present, otherwise fall back to `dotnet `. + // A .NET MTP app is typically shipped as a dll with a sibling native apphost. Prefer the + // apphost if it is a usable executable for this platform, otherwise fall back to + // `dotnet `. The apphost file name is OS-specific: `Foo.exe` on Windows, but an + // extension-less `Foo` on Unix. Probing for `.exe` unconditionally is wrong on Unix: a + // payload built on Windows and run on Unix (for example tests built on a Windows agent and + // executed on a Linux Helix machine) can drag a Windows PE `Foo.exe` next to the dll. + // Launching that yields "Permission denied" (or "Exec format error" once it is +x), so the + // probe must be OS-aware and, on Unix, insist the candidate is actually executable. +#if NETFRAMEWORK + // .NET Framework only runs on Windows, where the apphost is .exe. string apphost = Path.ChangeExtension(source, ".exe"); - return File.Exists(apphost) +#else + string apphost = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.ChangeExtension(source, ".exe") + : Path.ChangeExtension(source, null); +#endif + + return IsUsableApphost(apphost) ? (apphost, serverArgs, workingDirectory) : ("dotnet", $"\"{source}\" {serverArgs}", workingDirectory); } + /// + /// Determines whether can be launched directly as a native executable + /// on the current platform. On Windows, presence is sufficient. On Unix, when built for a target + /// framework that exposes File.GetUnixFileMode (.NET 7+), the file must also + /// carry an execute bit; a file that merely exists (for example a Windows PE copied onto Unix) is + /// not a usable apphost and the caller should fall back to dotnet <dll>. On target + /// frameworks without that API (e.g. netstandard2.0) this degrades to an existence-only check — + /// the OS-aware probe in already avoids the Windows-.exe-on-Unix + /// case, so the execute-bit check is defence-in-depth rather than the primary guard. + /// + internal static bool IsUsableApphost(string apphost) + { + if (!File.Exists(apphost)) + { + return false; + } + +#if NET + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + const UnixFileMode executeBits = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(apphost) & executeBits) != 0; + } +#endif + return true; + } + public void Dispose() { if (_disposed) diff --git a/test/Microsoft.TestPlatform.CrossPlatEngine.UnitTests/Client/MTP/MtpServerConnectionTests.cs b/test/Microsoft.TestPlatform.CrossPlatEngine.UnitTests/Client/MTP/MtpServerConnectionTests.cs new file mode 100644 index 0000000000..d0f16c5c01 --- /dev/null +++ b/test/Microsoft.TestPlatform.CrossPlatEngine.UnitTests/Client/MTP/MtpServerConnectionTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; + +using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Client.MTP; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace TestPlatform.CrossPlatEngine.UnitTests.Client.MTP; + +[TestClass] +public class MtpServerConnectionTests +{ + private const int Port = 12345; + + private string _tempDir = null!; + + [TestInitialize] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "mtp-buildlaunch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TestCleanup] + public void TearDown() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // best-effort cleanup + } + } + + [TestMethod] + public void BuildLaunchWhenSourceIsExeLaunchesItDirectly() + { + string exe = Path.Combine(_tempDir, "Foo.exe"); + File.WriteAllText(exe, string.Empty); + + var (fileName, arguments, workingDirectory) = MtpServerConnection.BuildLaunch(exe, Port); + + Assert.AreEqual(exe, fileName); + Assert.DoesNotContain("\"", arguments); + Assert.AreEqual(_tempDir, workingDirectory); + } + + [TestMethod] + public void BuildLaunchWhenDllHasNoApphostFallsBackToDotnet() + { + string dll = Path.Combine(_tempDir, "Foo.dll"); + File.WriteAllText(dll, string.Empty); + + var (fileName, arguments, _) = MtpServerConnection.BuildLaunch(dll, Port); + + Assert.AreEqual("dotnet", fileName); + Assert.Contains($"\"{dll}\"", arguments); + } + + [TestMethod] + [OSCondition(OperatingSystems.Linux | OperatingSystems.OSX)] + public void BuildLaunchOnUnixIgnoresSiblingWindowsExeAndFallsBackToDotnet() + { + string dll = Path.Combine(_tempDir, "Foo.dll"); + File.WriteAllText(dll, string.Empty); + + // Stand in for a Windows PE apphost dragged along in a Windows-built payload that is then + // unzipped on Linux: the file exists but is not a native Unix executable. + File.WriteAllText(Path.Combine(_tempDir, "Foo.exe"), string.Empty); + + var (fileName, arguments, _) = MtpServerConnection.BuildLaunch(dll, Port); + + Assert.AreEqual("dotnet", fileName); + Assert.Contains($"\"{dll}\"", arguments); + } + + [TestMethod] + [OSCondition(OperatingSystems.Windows)] + public void BuildLaunchOnWindowsSelectsSiblingExeApphost() + { + string dll = Path.Combine(_tempDir, "Foo.dll"); + File.WriteAllText(dll, string.Empty); + + // On Windows the apphost is .exe and its mere presence is sufficient. + string exe = Path.Combine(_tempDir, "Foo.exe"); + File.WriteAllText(exe, string.Empty); + + var (fileName, arguments, _) = MtpServerConnection.BuildLaunch(dll, Port); + + Assert.AreEqual(exe, fileName); + Assert.DoesNotContain("\"", arguments); + } + + [TestMethod] + public void IsUsableApphostReturnsFalseWhenFileMissing() + { + Assert.IsFalse(MtpServerConnection.IsUsableApphost(Path.Combine(_tempDir, "does-not-exist"))); + } + +#if NET + [TestMethod] + [OSCondition(OperatingSystems.Linux | OperatingSystems.OSX)] + public void IsUsableApphostOnUnixReturnsFalseForNonExecutableFile() + { + string apphost = Path.Combine(_tempDir, "Foo"); + File.WriteAllText(apphost, string.Empty); + File.SetUnixFileMode(apphost, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + Assert.IsFalse(MtpServerConnection.IsUsableApphost(apphost)); + } + + [TestMethod] + [OSCondition(OperatingSystems.Linux | OperatingSystems.OSX)] + public void IsUsableApphostOnUnixReturnsTrueForExecutableFile() + { + string apphost = Path.Combine(_tempDir, "Foo"); + File.WriteAllText(apphost, string.Empty); + File.SetUnixFileMode(apphost, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + Assert.IsTrue(MtpServerConnection.IsUsableApphost(apphost)); + } + + [TestMethod] + [OSCondition(OperatingSystems.Linux | OperatingSystems.OSX)] + public void BuildLaunchOnUnixSelectsExecutableExtensionlessApphost() + { + string dll = Path.Combine(_tempDir, "Foo.dll"); + File.WriteAllText(dll, string.Empty); + + string apphost = Path.Combine(_tempDir, "Foo"); + File.WriteAllText(apphost, string.Empty); + File.SetUnixFileMode(apphost, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var (fileName, _, _) = MtpServerConnection.BuildLaunch(dll, Port); + + Assert.AreEqual(apphost, fileName); + } +#endif +}