diff --git a/source/Shellfish/PasteArguments.cs b/source/Shellfish/PasteArguments.cs new file mode 100644 index 0000000..359a738 --- /dev/null +++ b/source/Shellfish/PasteArguments.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Octopus Modification: This code is derived from +// https://github.com/dotnet/runtime/blob/9f54e5162a177b8d6ad97ba53c6974fb02d0a47d/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs +// .NET uses it to implement the ProcessStartInfo.ArgumentList property, which we do not have access to in .NET Framework. +// We copy it here to provide a polyfill for older frameworks +// +// The following changes have been made: +// - Namespace changed to Octopus.Shellfish +// - Made class non-partial +// - Changed to use regular StringBuilder as ValueStringBuilder is internal to the CLR +// - Added static JoinArguments function to encapsulate the logic of joining arguments + +using System.Collections.Generic; +using System.Text; + +namespace Octopus.Shellfish; + +static class PasteArguments +{ + internal static string JoinArguments(IEnumerable arguments) + { + var stringBuilder = new StringBuilder(); + foreach(var argument in arguments) + { + AppendArgument(stringBuilder, argument); + } + + return stringBuilder.ToString(); + } + + internal static void AppendArgument(StringBuilder stringBuilder, string argument) + { + if (stringBuilder.Length != 0) + { + stringBuilder.Append(' '); + } + + // Parsing rules for non-argv[0] arguments: + // - Backslash is a normal character except followed by a quote. + // - 2N backslashes followed by a quote ==> N literal backslashes followed by unescaped quote + // - 2N+1 backslashes followed by a quote ==> N literal backslashes followed by a literal quote + // - Parsing stops at first whitespace outside of quoted region. + // - (post 2008 rule): A closing quote followed by another quote ==> literal quote, and parsing remains in quoting mode. + if (argument.Length != 0 && ContainsNoWhitespaceOrQuotes(argument)) + { + // Simple case - no quoting or changes needed. + stringBuilder.Append(argument); + } + else + { + stringBuilder.Append(Quote); + int idx = 0; + while (idx < argument.Length) + { + char c = argument[idx++]; + if (c == Backslash) + { + int numBackSlash = 1; + while (idx < argument.Length && argument[idx] == Backslash) + { + idx++; + numBackSlash++; + } + + if (idx == argument.Length) + { + // We'll emit an end quote after this so must double the number of backslashes. + stringBuilder.Append(Backslash, numBackSlash * 2); + } + else if (argument[idx] == Quote) + { + // Backslashes will be followed by a quote. Must double the number of backslashes. + stringBuilder.Append(Backslash, numBackSlash * 2 + 1); + stringBuilder.Append(Quote); + idx++; + } + else + { + // Backslash will not be followed by a quote, so emit as normal characters. + stringBuilder.Append(Backslash, numBackSlash); + } + + continue; + } + + if (c == Quote) + { + // Escape the quote so it appears as a literal. This also guarantees that we won't end up generating a closing quote followed + // by another quote (which parses differently pre-2008 vs. post-2008.) + stringBuilder.Append(Backslash); + stringBuilder.Append(Quote); + continue; + } + + stringBuilder.Append(c); + } + + stringBuilder.Append(Quote); + } + } + + private static bool ContainsNoWhitespaceOrQuotes(string s) + { + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (char.IsWhiteSpace(c) || c == Quote) + { + return false; + } + } + + return true; + } + + private const char Quote = '\"'; + private const char Backslash = '\\'; +} \ No newline at end of file diff --git a/source/Shellfish/Plumbing/PlatformDetection.cs b/source/Shellfish/Plumbing/PlatformDetection.cs deleted file mode 100644 index 1f17570..0000000 --- a/source/Shellfish/Plumbing/PlatformDetection.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace Octopus.Shellfish.Plumbing -{ - static class PlatformDetection - { - public static bool IsRunningOnWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - } -} \ No newline at end of file diff --git a/source/Shellfish/Plumbing/ProcessIdentity.cs b/source/Shellfish/Plumbing/ProcessIdentity.cs index 815ea0e..123564a 100644 --- a/source/Shellfish/Plumbing/ProcessIdentity.cs +++ b/source/Shellfish/Plumbing/ProcessIdentity.cs @@ -1,16 +1,13 @@ using System; +using System.Runtime.InteropServices; using System.Security.Principal; namespace Octopus.Shellfish.Plumbing { static class ProcessIdentity { - public static string CurrentUserName => PlatformDetection.IsRunningOnWindows - ? -#pragma warning disable PC001 // API not supported on all platforms - WindowsIdentity.GetCurrent().Name - : -#pragma warning restore PC001 // API not supported on all platforms - Environment.UserName; + public static string CurrentUserName => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? WindowsIdentity.GetCurrent().Name + : Environment.UserName; } } \ No newline at end of file diff --git a/source/Shellfish/ShellCommandExecutor.cs b/source/Shellfish/ShellCommandExecutor.cs new file mode 100644 index 0000000..0b3541a --- /dev/null +++ b/source/Shellfish/ShellCommandExecutor.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Octopus.Shellfish; + +// This is the NEW shellfish API. It is currently under development +public class ShellCommandExecutor +{ + string? executable; + string? commandLinePrefixArgument; // special case to allow WithDotNetExecutable to work + List? commandLineArguments; + string? rawCommandLineArguments; + string? workingDirectory; + + List? stdOutTargets; + List? stdErrTargets; + + public ShellCommandExecutor WithExecutable(string exe) + { + executable = exe; + return this; + } + + public ShellCommandExecutor WithWorkingDirectory(string workingDir) + { + workingDirectory = workingDir; + return this; + } + + // Configures the runner to launch the specified executable if it is a .exe, or to launch the specified .dll using dotnet.exe if it is a .dll. + // assumes "dotnet" is in the PATH somewhere + public ShellCommandExecutor WithDotNetExecutable(string exeOrDll) + { + if (exeOrDll.EndsWith(".dll")) + { + commandLinePrefixArgument = exeOrDll; + executable = "dotnet"; + } + + return this; + } + + public ShellCommandExecutor WithArguments(params string[] arguments) + { + commandLineArguments ??= new List(); + commandLineArguments.Clear(); + commandLineArguments.AddRange(arguments); + return this; + } + + /// + /// Allows you to supply a string which will be passed directly to Process.StartInfo.Arguments, + /// can be useful if you have custom quoting requirements or other special needs. + /// + public ShellCommandExecutor WithRawArguments(string rawArguments) + { + rawCommandLineArguments = rawArguments; + return this; + } + + public ShellCommandExecutor CapturingStdOut() + { + stdOutTargets ??= new List(); + if (stdOutTargets.Any(t => t is CapturedStringBuilderTarget)) return this; // already capturing + stdOutTargets.Add(new CapturedStringBuilderTarget()); + return this; + } + + public ShellCommandExecutor CapturingStdErr() + { + stdErrTargets ??= new List(); + if (stdErrTargets.Any(t => t is CapturedStringBuilderTarget)) return this; // already capturing + stdErrTargets.Add(new CapturedStringBuilderTarget()); + return this; + } + + // sets standard flags on the Process that apply in all cases + void ConfigureProcess(Process process, out bool shouldBeginOutputRead, out bool shouldBeginErrorRead) + { + process.StartInfo.FileName = executable!; + + if (rawCommandLineArguments is not null && commandLineArguments is { Count: > 0 }) throw new InvalidOperationException("Cannot specify both raw arguments and arguments"); + + if (rawCommandLineArguments is not null) + { + process.StartInfo.Arguments = rawCommandLineArguments; + } + else if (commandLineArguments is { Count: > 0 }) + { +#if NET5_0_OR_GREATER + // Prefer ArgumentList if we're on net5.0 or greater. Our polyfill should have the same behaviour, but + // If we stick with the CLR we will pick up optimizations and bugfixes going forward + if (commandLinePrefixArgument is not null) + { + process.StartInfo.ArgumentList.Add(commandLinePrefixArgument); + } + + foreach (var arg in commandLineArguments) process.StartInfo.ArgumentList.Add(arg); +#else + var fullArgs = commandLinePrefixArgument is not null + ? [commandLinePrefixArgument, ..commandLineArguments] + : commandLineArguments; + + process.StartInfo.Arguments = PasteArguments.JoinArguments(fullArgs); +#endif + } + else if (commandLinePrefixArgument is not null) + { + // e.g. WithDotNetExecutable("foo.dll") with no other args; and we need to do "dotnet foo.dll" + process.StartInfo.Arguments = commandLinePrefixArgument; + } + + if (workingDirectory is not null) process.StartInfo.WorkingDirectory = workingDirectory; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + + shouldBeginOutputRead = shouldBeginErrorRead = false; + if (stdOutTargets is + { + Count: > 0 + }) + { + process.StartInfo.RedirectStandardOutput = true; + shouldBeginOutputRead = true; + + var targets = stdOutTargets.ToArray(); + process.OutputDataReceived += (_, e) => + { + foreach (var target in targets) + { + target.DataReceived(e.Data); + } + }; + } + + if (stdErrTargets is { Count: > 0 }) + { + process.StartInfo.RedirectStandardError = true; + shouldBeginErrorRead = true; + + var targets = stdErrTargets.ToArray(); + process.ErrorDataReceived += (_, e) => + { + foreach (var target in targets) + { + target.DataReceived(e.Data); + } + }; + } + } + + public ShellCommandResult Execute(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(executable)) throw new InvalidOperationException("No executable specified"); + + var process = new Process(); + ConfigureProcess(process, out var shouldBeginOutputRead, out var shouldBeginErrorRead); + + process.Start(); + + if (shouldBeginOutputRead) process.BeginOutputReadLine(); + if (shouldBeginErrorRead) process.BeginErrorReadLine(); + + try + { + if (cancellationToken == default) + { + process.WaitForExit(); + } + else // cancellation is hard + { + WaitForExitInNewThread(process, cancellationToken).GetAwaiter().GetResult(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (shouldBeginOutputRead) process.CancelOutputRead(); + if (shouldBeginErrorRead) process.CancelErrorRead(); + throw; + } + + return new ShellCommandResult( + SafelyGetExitCode(process), + stdOutTargets?.OfType().FirstOrDefault()?.StringBuilder, + stdErrTargets?.OfType().FirstOrDefault()?.StringBuilder); + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(executable)) throw new InvalidOperationException("No executable specified"); + + var process = new Process(); + ConfigureProcess(process, out var shouldBeginOutputRead, out var shouldBeginErrorRead); + process.Start(); + + if (shouldBeginOutputRead) process.BeginOutputReadLine(); + if (shouldBeginErrorRead) process.BeginErrorReadLine(); + + try + { +#if NET5_0_OR_GREATER // WaitForExitAsync was added in net5; we can't use it when targeting netstandard2.0 + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); +#else // fake it out. + await WaitForExitInNewThread(process, cancellationToken).ConfigureAwait(false); +#endif + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (shouldBeginOutputRead) process.CancelOutputRead(); + if (shouldBeginErrorRead) process.CancelErrorRead(); + throw; + } + + return new ShellCommandResult( + SafelyGetExitCode(process), + stdOutTargets?.OfType().FirstOrDefault()?.StringBuilder, + stdErrTargets?.OfType().FirstOrDefault()?.StringBuilder); + } + + static int SafelyGetExitCode(Process process) + { + try + { + return process.ExitCode; + } + catch (InvalidOperationException ex) + when (ex.Message is "No process is associated with this object." or "Process was not started by this object, so requested information cannot be determined.") + { + return -1; + } + } + + static Task WaitForExitInNewThread(Process process, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + + CancellationTokenRegistration registration = default; + registration = cancellationToken.Register(() => + { + registration.Dispose(); + tcs.TrySetCanceled(); + }); + + new Thread(() => + { + try + { + process.WaitForExit(); + tcs.TrySetResult(true); + } + catch (Exception e) + { + tcs.TrySetException(e); + } + }).Start(); + return tcs.Task; + } + + interface IOutputTarget + { + void DataReceived(string? line); + } + + class CapturedStringBuilderTarget : IOutputTarget + { + public void DataReceived(string? line) => StringBuilder.AppendLine(line); + + public StringBuilder StringBuilder { get; } = new(); + } +} \ No newline at end of file diff --git a/source/Shellfish/ShellCommandResult.cs b/source/Shellfish/ShellCommandResult.cs new file mode 100644 index 0000000..63d3815 --- /dev/null +++ b/source/Shellfish/ShellCommandResult.cs @@ -0,0 +1,30 @@ +using System.Text; + +namespace Octopus.Shellfish; + +// This is the NEW shellfish API. It is currently under development + +/// +/// Holds the result of a shell command execution. Typically an exit code, but may also include stdout/stderr, etc +/// if those were configured to be captured. +/// +public class ShellCommandResult(int exitCode, StringBuilder? stdOutBuffer = null, StringBuilder? stdErrBuffer = null) +{ + + /// + /// The shell command exit code + /// + public int ExitCode { get; } = exitCode; + + /// + /// If CaptureStdOut() was configured, this will contain the stdout of the command. + /// If not, it will be null + /// + public StringBuilder? StdOutBuffer { get; } = stdOutBuffer; + + /// + /// If CaptureStdErr() was configured, this will contain the stdout of the command. + /// If not, it will be null + /// + public StringBuilder? StdErrBuffer { get; } = stdErrBuffer; +} \ No newline at end of file diff --git a/source/Shellfish/ShellExecutor.cs b/source/Shellfish/ShellExecutor.cs index cf02d47..7977034 100644 --- a/source/Shellfish/ShellExecutor.cs +++ b/source/Shellfish/ShellExecutor.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Runtime.InteropServices; using System.Threading; using Octopus.Shellfish.Nix; using Octopus.Shellfish.Plumbing; @@ -13,7 +14,7 @@ namespace Octopus.Shellfish { public static class ShellExecutor { - static readonly IXPlatAdapter XPlatAdapter = PlatformDetection.IsRunningOnWindows + static readonly IXPlatAdapter XPlatAdapter = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? (IXPlatAdapter)new WindowsAdapter() : new NixAdapter(); @@ -125,7 +126,7 @@ void WriteData(Action action, ManualResetEventSlim resetEvent, DataRecei process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; - if (PlatformDetection.IsRunningOnWindows) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { process.StartInfo.StandardOutputEncoding = encoding; process.StartInfo.StandardErrorEncoding = encoding; diff --git a/source/Shellfish/Shellfish.csproj b/source/Shellfish/Shellfish.csproj index 7eefaa4..eb96dd2 100644 --- a/source/Shellfish/Shellfish.csproj +++ b/source/Shellfish/Shellfish.csproj @@ -11,7 +11,7 @@ true 12 true - netstandard2.0 + netstandard2.0;net8.0 diff --git a/source/Shellfish/Windows/UserProfile.cs b/source/Shellfish/Windows/UserProfile.cs index 7dec43a..fb7dec0 100644 --- a/source/Shellfish/Windows/UserProfile.cs +++ b/source/Shellfish/Windows/UserProfile.cs @@ -1,9 +1,13 @@ using System; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using Microsoft.Win32.SafeHandles; namespace Octopus.Shellfish.Windows { +#if NET5_0_OR_GREATER + [SupportedOSPlatform("Windows")] +#endif class UserProfile : IDisposable { readonly AccessToken token; diff --git a/source/Shellfish/Windows/WindowStationAndDesktopAccess.cs b/source/Shellfish/Windows/WindowStationAndDesktopAccess.cs index acb11d2..780bfdf 100644 --- a/source/Shellfish/Windows/WindowStationAndDesktopAccess.cs +++ b/source/Shellfish/Windows/WindowStationAndDesktopAccess.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Security.AccessControl; using System.Security.Principal; @@ -7,6 +8,9 @@ namespace Octopus.Shellfish.Windows { // Required to allow a service to run a process as another user // See http://stackoverflow.com/questions/677874/starting-a-process-with-credentials-from-a-windows-service/30687230#30687230 +#if NET5_0_OR_GREATER + [SupportedOSPlatform("Windows")] +#endif static class WindowStationAndDesktopAccess { public static void GrantAccessToWindowStationAndDesktop(string username, string? domainName = null) diff --git a/source/Shellfish/Windows/WindowsAdapter.cs b/source/Shellfish/Windows/WindowsAdapter.cs index 6d37c94..2a366d0 100644 --- a/source/Shellfish/Windows/WindowsAdapter.cs +++ b/source/Shellfish/Windows/WindowsAdapter.cs @@ -5,10 +5,14 @@ using System.Management; using System.Net; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text; namespace Octopus.Shellfish.Windows { +#if NET5_0_OR_GREATER + [SupportedOSPlatform("Windows")] +#endif class WindowsAdapter : IXPlatAdapter { public void RunAsDifferentUser(ProcessStartInfo startInfo, NetworkCredential runAs, IDictionary? customEnvironmentVariables) diff --git a/source/Shellfish/Windows/WindowsEnvironmentVariableHelper.cs b/source/Shellfish/Windows/WindowsEnvironmentVariableHelper.cs index 48641f1..fdac55e 100644 --- a/source/Shellfish/Windows/WindowsEnvironmentVariableHelper.cs +++ b/source/Shellfish/Windows/WindowsEnvironmentVariableHelper.cs @@ -4,9 +4,13 @@ using System.Diagnostics; using System.Linq; using System.Net; +using System.Runtime.Versioning; namespace Octopus.Shellfish.Windows { +#if NET5_0_OR_GREATER + [SupportedOSPlatform("Windows")] +#endif static class WindowsEnvironmentVariableHelper { static readonly object EnvironmentVariablesCacheLock = new object(); diff --git a/source/Tests/ShellExecutorFixture.cs b/source/Tests/ShellExecutorFixture.cs index 681e0c8..1854e7b 100644 --- a/source/Tests/ShellExecutorFixture.cs +++ b/source/Tests/ShellExecutorFixture.cs @@ -20,8 +20,8 @@ public class ShellExecutorFixture const int SIG_KILL = 137; const string Username = "test-shellexecutor"; - static readonly string Command = PlatformDetection.IsRunningOnWindows ? "cmd.exe" : "bash"; - static readonly string CommandParam = PlatformDetection.IsRunningOnWindows ? "/c" : "-c"; + static readonly string Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "bash"; + static readonly string CommandParam = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "/c" : "-c"; // Mimic the cancellation behaviour from LoggedTest in Octopus Server; we can't reference it in this assembly static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(45); @@ -251,7 +251,7 @@ public void CancellationToken_ShouldForceKillTheProcess() customEnvironmentVariables, cts.Token); - if (PlatformDetection.IsRunningOnWindows) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { exitCode.Should().BeLessOrEqualTo(0, "the process should have been terminated"); infoMessages.ToString().Should().ContainEquivalentOf("Microsoft Windows", "the default command-line header would be written to stdout"); @@ -313,7 +313,7 @@ public void EchoError_ShouldWriteToStdErr() [Fact] public void RunAsCurrentUser_ShouldWork() { - var arguments = PlatformDetection.IsRunningOnWindows + var arguments = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"{CommandParam} \"echo {EchoEnvironmentVariable("username")}\"" : $"{CommandParam} \"whoami\""; var workingDirectory = ""; @@ -386,7 +386,7 @@ public void RunAsDifferentUser_ShouldWork(string command, string arguments) } static string EchoEnvironmentVariable(string varName) - => PlatformDetection.IsRunningOnWindows ? $"%{varName}%" : $"${varName}"; + => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"%{varName}%" : $"${varName}"; static int Execute( string command,