Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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 <dll>`.
// 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 <dll>`. 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 <name>.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);
}

/// <summary>
/// Determines whether <paramref name="apphost"/> 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 <c>File.GetUnixFileMode</c> (.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 <c>dotnet &lt;dll&gt;</c>. On target
/// frameworks without that API (e.g. netstandard2.0) this degrades to an existence-only check —
/// the OS-aware probe in <see cref="BuildLaunch"/> already avoids the Windows-<c>.exe</c>-on-Unix
/// case, so the execute-bit check is defence-in-depth rather than the primary guard.
/// </summary>
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <name>.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
}