From 817f436f5e5f32825feb462a2d900ec849697cf2 Mon Sep 17 00:00:00 2001 From: Charlie Poole Date: Mon, 7 Jul 2025 09:16:40 -0700 Subject: [PATCH] Eliminate launcher dependency on nunit.agent.core --- build.cake | 7 +- choco/net90-pluggable-agent.nuspec | 1 - nuget/Net90PluggableAgent.nuspec | 1 - src/agent/nunit-agent-net90.csproj | 1 + src/launcher/DotNet.cs | 83 ++++++++++++++++ src/launcher/Net90AgentLauncher.cs | 80 ++++++++++++++-- src/launcher/ProcessUtils.cs | 96 +++++++++++++++++++ .../nunit-agent-launcher-net90.csproj | 4 +- src/tests/Net90AgentLauncherTests.cs | 6 +- 9 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 src/launcher/DotNet.cs create mode 100644 src/launcher/ProcessUtils.cs diff --git a/build.cake b/build.cake index a253099..955981b 100644 --- a/build.cake +++ b/build.cake @@ -10,7 +10,8 @@ BuildSettings.Initialize solutionFile: "net90-pluggable-agent.sln", unitTests: "**/*.tests.exe", githubOwner: "NUnit", - githubRepository: "net90-pluggable-agent" + githubRepository: "net90-pluggable-agent", + exemptFiles: new[] { "ProcessUtils.cs" } ); var PackageTests = new PackageTest[] { @@ -133,7 +134,7 @@ BuildSettings.Packages.Add(new NuGetPackage( { HasFiles("LICENSE.txt", "README.md", "nunit_256.png"), HasDirectory("tools").WithFiles( - "nunit-agent-launcher-net90.dll", "nunit.engine.api.dll", "nunit.agent.core.dll"), + "nunit-agent-launcher-net90.dll", "nunit.engine.api.dll"), HasDirectory("tools/agent").WithFiles( "nunit-agent-net90.dll", "nunit.engine.api.dll", "nunit.common.dll", "nunit.extensibility.api.dll", "nunit.extensibility.dll", "nunit.agent.core.dll", @@ -150,7 +151,7 @@ BuildSettings.Packages.Add(new ChocolateyPackage( { HasDirectory("tools").WithFiles( "LICENSE.txt", "README.md", "nunit_256.png", "VERIFICATION.txt", - "nunit-agent-launcher-net90.dll", "nunit.engine.api.dll", "nunit.agent.core.dll"), + "nunit-agent-launcher-net90.dll", "nunit.engine.api.dll"), HasDirectory("tools/agent").WithFiles( "nunit-agent-net90.dll", "nunit.engine.api.dll", "nunit.common.dll", "nunit.extensibility.api.dll", "nunit.extensibility.dll", "nunit.agent.core.dll", diff --git a/choco/net90-pluggable-agent.nuspec b/choco/net90-pluggable-agent.nuspec index 664927b..b9d6c87 100644 --- a/choco/net90-pluggable-agent.nuspec +++ b/choco/net90-pluggable-agent.nuspec @@ -29,7 +29,6 @@ - diff --git a/nuget/Net90PluggableAgent.nuspec b/nuget/Net90PluggableAgent.nuspec index fe20d00..aba98bc 100644 --- a/nuget/Net90PluggableAgent.nuspec +++ b/nuget/Net90PluggableAgent.nuspec @@ -25,7 +25,6 @@ - diff --git a/src/agent/nunit-agent-net90.csproj b/src/agent/nunit-agent-net90.csproj index 412b61d..7364773 100644 --- a/src/agent/nunit-agent-net90.csproj +++ b/src/agent/nunit-agent-net90.csproj @@ -10,6 +10,7 @@ false true ..\nunit.snk + Enable diff --git a/src/launcher/DotNet.cs b/src/launcher/DotNet.cs new file mode 100644 index 0000000..99d1f5e --- /dev/null +++ b/src/launcher/DotNet.cs @@ -0,0 +1,83 @@ +// Copyright (c) Charlie Poole, Rob Prouse and Contributors. MIT License - see LICENSE.txt + +using Microsoft.Win32; +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace NUnit.Common +{ + public static class DotNet + { + public static string? GetInstallDirectory() => Environment.Is64BitProcess + ? GetX64InstallDirectory() : GetX86InstallDirectory(); + + public static string? GetInstallDirectory(bool x86) => x86 + ? GetX86InstallDirectory() : GetX64InstallDirectory(); + + private static string? _x64InstallDirectory; + public static string? GetX64InstallDirectory() + { + if (_x64InstallDirectory is null) + _x64InstallDirectory = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + + if (_x64InstallDirectory is null) + { +#if NETFRAMEWORK + if (Path.DirectorySeparatorChar == '\\') +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) +#endif + { + using (RegistryKey? key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\dotnet\SetUp\InstalledVersions\x64\sharedHost\")) + _x64InstallDirectory = (string?)key?.GetValue("Path"); + } + else + _x64InstallDirectory = "/usr/shared/dotnet/"; + } + + return _x64InstallDirectory; + } + + private static string? _x86InstallDirectory; + public static string? GetX86InstallDirectory() + { + if (_x86InstallDirectory is null) + _x86InstallDirectory = Environment.GetEnvironmentVariable("DOTNET_ROOT_X86"); + + if (_x86InstallDirectory is null) + { +#if NETFRAMEWORK + if (Path.DirectorySeparatorChar == '\\') +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) +#endif + { + using (RegistryKey? key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\dotnet\SetUp\InstalledVersions\x86\")) + _x86InstallDirectory = (string?)key?.GetValue("InstallLocation"); + } + else + _x86InstallDirectory = "/usr/shared/dotnet/"; + } + + return _x86InstallDirectory; + } + + public static string GetDotNetExe(bool runAsX86) + { + string? installDirectory = DotNet.GetInstallDirectory(runAsX86); + if (installDirectory is not null) + { + var dotnet_exe = Path.Combine(installDirectory, "dotnet.exe"); + if (File.Exists(dotnet_exe)) + return dotnet_exe; + } + + var msg = runAsX86 + ? "The X86 version of dotnet.exe is not installed." + : "Unable to locate dotnet.exe."; + + throw new Exception(msg); + } + } +} diff --git a/src/launcher/Net90AgentLauncher.cs b/src/launcher/Net90AgentLauncher.cs index c69d897..ccb711a 100644 --- a/src/launcher/Net90AgentLauncher.cs +++ b/src/launcher/Net90AgentLauncher.cs @@ -2,25 +2,89 @@ #if NETFRAMEWORK using System; +using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.Versioning; -using NUnit.Agents; +using System.Text; using NUnit.Common; +using NUnit.Engine.Extensibility; using NUnit.Extensibility; namespace NUnit.Engine.Agents { - [Extension] - public class Net90AgentLauncher : LocalProcessAgentLauncher + [Extension(Description = "Pluggable agent running tests under .NET 8.0", EngineVersion = "4.0.0")] + public class Net90AgentLauncher : IAgentLauncher { - private static readonly string LAUNCHER_DIR = AssemblyHelper.GetDirectoryName(Assembly.GetExecutingAssembly()); + private static readonly string LAUNCHER_DIR = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - protected override string AgentName => "Net90Agent"; - protected override TestAgentType AgentType => TestAgentType.LocalProcess; - protected override FrameworkName AgentRuntime => new FrameworkName(FrameworkIdentifiers.NetCoreApp, new Version(9, 0, 0)); + private const string RUNTIME_IDENTIFIER = ".NETCoreApp"; + private static readonly Version RUNTIME_VERSION = new Version(9, 0, 0); + private static readonly FrameworkName TARGET_FRAMEWORK = new FrameworkName(RUNTIME_IDENTIFIER, RUNTIME_VERSION); - protected override string AgentPath => Path.Combine(LAUNCHER_DIR, $"agent/nunit-agent-net90.dll"); + protected string AgentPath => Path.Combine(LAUNCHER_DIR, $"agent/nunit-agent-net90.dll"); + + public TestAgentInfo AgentInfo => new TestAgentInfo( + GetType().Name, + TestAgentType.LocalProcess, + TARGET_FRAMEWORK); + + public bool CanCreateAgent(TestPackage package) + { + // Get target runtime from package + string runtimeSetting = package.Settings.GetValueOrDefault(SettingDefinitions.TargetFrameworkName); + var targetRuntime = new FrameworkName(runtimeSetting); + bool runAsX86 = package.Settings.GetValueOrDefault(SettingDefinitions.RunAsX86); + + return targetRuntime.Identifier == RUNTIME_IDENTIFIER && targetRuntime.Version.Major <= RUNTIME_VERSION.Major; + } + + public Process CreateAgent(Guid agentId, string agencyUrl, TestPackage package) + { + // Should not be called unless we have previously checked CanCreateAgent + if (!CanCreateAgent(package)) + throw new ArgumentException("Unable to create agent. Check result of CanCreateAgent before calling CreateAgent.", nameof(package)); + + var process = new Process() + { + EnableRaisingEvents = true + }; + + // Access package settings + var settings = package.Settings; + bool runAsX86 = settings.GetValueOrDefault(SettingDefinitions.RunAsX86); + bool debugTests = settings.GetValueOrDefault(SettingDefinitions.DebugTests); + bool debugAgent = settings.GetValueOrDefault(SettingDefinitions.DebugAgent); + string traceLevel = settings.GetValueOrDefault(SettingDefinitions.InternalTraceLevel); + bool loadUserProfile = settings.GetValueOrDefault(SettingDefinitions.LoadUserProfile); + string workDirectory = settings.GetValueOrDefault(SettingDefinitions.WorkDirectory); + + var sb = new StringBuilder($"--agentId={agentId} --agencyUrl={agencyUrl} --pid={Process.GetCurrentProcess().Id}"); + + // Set options that need to be in effect before the package + // is loaded by using the command line. + if (traceLevel != "Off") + sb.Append(" --trace=").EscapeProcessArgument(traceLevel); + if (debugAgent) + sb.Append(" --debug-agent"); + if (debugTests) + sb.Append(" --debug-tests"); + if (workDirectory != string.Empty) + sb.Append(" --work=").EscapeProcessArgument(workDirectory); + + string arguments = sb.ToString(); + + var startInfo = process.StartInfo; + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + startInfo.WorkingDirectory = Environment.CurrentDirectory; + startInfo.LoadUserProfile = loadUserProfile; + + startInfo.FileName = DotNet.GetDotNetExe(runAsX86); + startInfo.Arguments = $"\"{AgentPath}\" {arguments}"; + + return process; + } } } #endif diff --git a/src/launcher/ProcessUtils.cs b/src/launcher/ProcessUtils.cs new file mode 100644 index 0000000..bbc7cd9 --- /dev/null +++ b/src/launcher/ProcessUtils.cs @@ -0,0 +1,96 @@ +// *********************************************************************** +// Copyright (c) 2016 Joseph N. Musser II +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// *********************************************************************** + +using System.Text; + +namespace NUnit.Common +{ + public static class ProcessUtils + { + private static readonly char[] CharsThatRequireQuoting = { ' ', '"' }; + private static readonly char[] CharsThatRequireEscaping = { '\\', '"' }; + + /// + /// Escapes arbitrary values so that the process receives the exact string you intend and injection is impossible. + /// Spec: https://docs.microsoft.com/en-gb/windows/desktop/api/shellapi/nf-shellapi-commandlinetoargvw + /// + public static void EscapeProcessArgument(this StringBuilder builder, string literalValue, bool alwaysQuote = false) + { + if (string.IsNullOrEmpty(literalValue)) + { + builder.Append("\"\""); + return; + } + + if (literalValue.IndexOfAny(CharsThatRequireQuoting) == -1) // Happy path + { + if (!alwaysQuote) + { + builder.Append(literalValue); + return; + } + if (literalValue[literalValue.Length - 1] != '\\') + { + builder.Append('"').Append(literalValue).Append('"'); + return; + } + } + + builder.Append('"'); + + var nextPosition = 0; + while (true) + { + var nextEscapeChar = literalValue.IndexOfAny(CharsThatRequireEscaping, nextPosition); + if (nextEscapeChar == -1) + break; + + builder.Append(literalValue, nextPosition, nextEscapeChar - nextPosition); + nextPosition = nextEscapeChar + 1; + + switch (literalValue[nextEscapeChar]) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + var numBackslashes = 1; + while (nextPosition < literalValue.Length && literalValue[nextPosition] == '\\') + { + numBackslashes++; + nextPosition++; + } + if (nextPosition == literalValue.Length || literalValue[nextPosition] == '"') + numBackslashes <<= 1; + + for (; numBackslashes != 0; numBackslashes--) + builder.Append('\\'); + break; + } + } + + builder.Append(literalValue, nextPosition, literalValue.Length - nextPosition); + builder.Append('"'); + } + } +} diff --git a/src/launcher/nunit-agent-launcher-net90.csproj b/src/launcher/nunit-agent-launcher-net90.csproj index 1bbf57c..72373ba 100644 --- a/src/launcher/nunit-agent-launcher-net90.csproj +++ b/src/launcher/nunit-agent-launcher-net90.csproj @@ -9,10 +9,12 @@ true true ..\nunit.snk + Enable - + + diff --git a/src/tests/Net90AgentLauncherTests.cs b/src/tests/Net90AgentLauncherTests.cs index a87c3cf..0cb8740 100644 --- a/src/tests/Net90AgentLauncherTests.cs +++ b/src/tests/Net90AgentLauncherTests.cs @@ -4,10 +4,8 @@ using System.Linq; using System.IO; using System.Diagnostics; -using System.Runtime.Versioning; using NUnit.Common; using NUnit.Framework; -using System.Drawing.Text; namespace NUnit.Engine.Agents { @@ -20,8 +18,8 @@ public class Net90AgentLauncherTests private static string TESTS_DIR = Path.Combine(TestContext.CurrentContext.TestDirectory, "tests"); // Constants used for settings - private const string NETFX = FrameworkIdentifiers.NetFramework; - private const string NETCORE = FrameworkIdentifiers.NetCoreApp; + private const string NETFX = ".NETFramework"; + private const string NETCORE = ".NETCoreApp"; private const string NET20 = $"{NETFX},Version=v2.0"; private const string NET30 = $"{NETFX},Version=v3.0"; private const string NET35 = $"{NETFX},Version=v3.5";