diff --git a/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.GetWindowsDirectoryW.cs b/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.GetWindowsDirectoryW.cs new file mode 100644 index 00000000000000..05710a47a019f9 --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.GetWindowsDirectoryW.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + internal static partial class Kernel32 + { + [LibraryImport(Libraries.Kernel32, SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint GetWindowsDirectoryW(ref char lpBuffer, uint uSize); + } +} diff --git a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs index 17366ae9811736..c6b6565016ea69 100644 --- a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs +++ b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs @@ -261,6 +261,17 @@ public ProcessStartInfo(string fileName, System.Collections.Generic.IEnumerable< [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string WorkingDirectory { get { throw null; } set { } } } + public sealed partial class ProcessStartOptions + { + public ProcessStartOptions(string fileName) { } + public System.Collections.Generic.IList Arguments { get { throw null; } set { } } + public bool CreateNewProcessGroup { get { throw null; } set { } } + public System.Collections.Generic.IDictionary Environment { get { throw null; } } + public string FileName { get { throw null; } } + public System.Collections.Generic.IList InheritedHandles { get { throw null; } set { } } + public bool KillOnParentExit { get { throw null; } set { } } + public string? WorkingDirectory { get { throw null; } set { } } + } [System.ComponentModel.DesignerAttribute("System.Diagnostics.Design.ProcessThreadDesigner, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a")] public partial class ProcessThread : System.ComponentModel.Component { diff --git a/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx b/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx index 3cc0d1e1d09e61..67d05840e9796b 100644 --- a/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx +++ b/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx @@ -336,4 +336,7 @@ Invalid performance counter data with type '{0}'. + + Could not resolve the file. + \ No newline at end of file diff --git a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj index 986ee67013d3d7..78eee4897b0b89 100644 --- a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj +++ b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj @@ -12,6 +12,7 @@ $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) SR.Process_PlatformNotSupported true + $(DefineConstants);WINDOWS @@ -26,6 +27,7 @@ + @@ -47,6 +49,8 @@ Link="Common\System\Text\ValueStringBuilder.cs" /> + @@ -136,6 +140,10 @@ Link="Common\Interop\Windows\Kernel32\Interop.GetProcessPriorityBoost.cs" /> + + - allowedProgramsToRun = ["xdg-open", "gnome-open", "kfmclient"]; foreach (var program in allowedProgramsToRun) { - string? pathToProgram = FindProgramInPath(program); + string? pathToProgram = ProcessStartOptions.FindProgramInPath(program); if (!string.IsNullOrEmpty(pathToProgram)) { return pathToProgram; diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.SunOS.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.SunOS.cs index f40076a3882aca..3a3bff81abd0a4 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.SunOS.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.SunOS.cs @@ -38,7 +38,7 @@ internal DateTime StartTimeCore /// Gets execution path private static string? GetPathToOpenFile() { - return FindProgramInPath("xdg-open"); + return ProcessStartOptions.FindProgramInPath("xdg-open"); } /// diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs index 2dde9eb778cdc9..fc46f14f918e7b 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs @@ -446,7 +446,7 @@ private bool StartCore(ProcessStartInfo startInfo) } else { - filename = ResolvePath(startInfo.FileName); + filename = ProcessStartOptions.ResolvePath(startInfo.FileName); argv = ParseArgv(startInfo); if (Directory.Exists(filename)) { @@ -663,7 +663,7 @@ private static string[] CreateEnvp(ProcessStartInfo psi) // find filename on PATH else { - resolvedFilename = FindProgramInPath(filename); + resolvedFilename = ProcessStartOptions.FindProgramInPath(filename); } } @@ -682,79 +682,7 @@ private static string[] CreateEnvp(ProcessStartInfo psi) } } - /// Resolves a path to the filename passed to ProcessStartInfo. - /// The filename. - /// The resolved path. It can return null in case of URLs. - private static string? ResolvePath(string filename) - { - // Follow the same resolution that Windows uses with CreateProcess: - // 1. First try the exact path provided - // 2. Then try the file relative to the executable directory - // 3. Then try the file relative to the current directory - // 4. then try the file in each of the directories specified in PATH - // Windows does additional Windows-specific steps between 3 and 4, - // and we ignore those here. - - // If the filename is a complete path, use it, regardless of whether it exists. - if (Path.IsPathRooted(filename)) - { - // In this case, it doesn't matter whether the file exists or not; - // it's what the caller asked for, so it's what they'll get - return filename; - } - - // Then check the executable's directory - string? path = Environment.ProcessPath; - if (path != null) - { - try - { - path = Path.Combine(Path.GetDirectoryName(path)!, filename); - if (File.Exists(path)) - { - return path; - } - } - catch (ArgumentException) { } // ignore any errors in data that may come from the exe path - } - - // Then check the current directory - path = Path.Combine(Directory.GetCurrentDirectory(), filename); - if (File.Exists(path)) - { - return path; - } - - // Then check each directory listed in the PATH environment variables - return FindProgramInPath(filename); - } - - /// - /// Gets the path to the program - /// - /// - /// - private static string? FindProgramInPath(string program) - { - string path; - string? pathEnvVar = Environment.GetEnvironmentVariable("PATH"); - if (pathEnvVar != null) - { - var pathParser = new StringParser(pathEnvVar, ':', skipEmpty: true); - while (pathParser.MoveNext()) - { - string subPath = pathParser.ExtractCurrent(); - path = Path.Combine(subPath, program); - if (IsExecutable(path)) - { - return path; - } - } - } - return null; - } - - private static bool IsExecutable(string fullPath) + internal static bool IsExecutable(string fullPath) { Interop.Sys.FileStatus fileinfo; diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartOptions.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartOptions.cs new file mode 100644 index 00000000000000..fb12746af8399a --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartOptions.cs @@ -0,0 +1,286 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace System.Diagnostics +{ + /// + /// Specifies options for starting a new process. + /// + public sealed class ProcessStartOptions + { + private readonly string _fileName; + private IList? _arguments; + private Dictionary? _environment; + private IList? _inheritedHandles; + + /// + /// Gets the application to start. + /// + public string FileName => _fileName; + + /// + /// Gets or sets the command-line arguments to pass to the application. + /// + public IList Arguments + { + get => _arguments ??= new List(); + set + { + ArgumentNullException.ThrowIfNull(value); + _arguments = value; + } + } + + /// + /// Gets the environment variables that apply to this process and its child processes. + /// + /// + /// By default, the environment is a copy of the current process environment. + /// + public IDictionary Environment + { + get + { + if (_environment == null) + { + IDictionary envVars = System.Environment.GetEnvironmentVariables(); + + _environment = new Dictionary( + envVars.Count, + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + + // Manual use of IDictionaryEnumerator instead of foreach to avoid DictionaryEntry box allocations. + IDictionaryEnumerator e = envVars.GetEnumerator(); + Debug.Assert(!(e is IDisposable), "Environment.GetEnvironmentVariables should not be IDisposable."); + while (e.MoveNext()) + { + DictionaryEntry entry = e.Entry; + _environment.Add((string)entry.Key, (string?)entry.Value); + } + } + return _environment; + } + } + + /// + /// Gets or sets the working directory for the process to be started. + /// + public string? WorkingDirectory { get; set; } + + /// + /// Gets a list of handles that will be inherited by the child process. + /// + /// + /// + /// Handles do not need to have inheritance enabled beforehand. + /// They are also not duplicated, just added as-is to the child process + /// so the exact same handle values can be used in the child process. + /// + /// + /// On Windows, the implementation will automatically enable inheritance on any handle added to this list + /// by modifying the handle's flags using SetHandleInformation. + /// + /// + /// On Unix, the implementation will modify the copy of every handle in the child process + /// by removing FD_CLOEXEC flag. It happens after the fork and before the exec, so it does not affect parent process. + /// + /// + public IList InheritedHandles + { + get => _inheritedHandles ??= new List(); + set + { + ArgumentNullException.ThrowIfNull(value); + _inheritedHandles = value; + } + } + + /// + /// Gets or sets a value indicating whether the child process should be terminated when the parent process exits. + /// + public bool KillOnParentExit { get; set; } + + /// + /// Gets or sets a value indicating whether to create the process in a new process group. + /// + /// + /// + /// Creating a new process group enables sending signals to the process (e.g., SIGINT, SIGQUIT) + /// on Windows and provides process group isolation on all platforms. + /// + /// + /// On Unix systems, child processes in a new process group won't receive signals sent to the parent's + /// process group, which can be useful for background processes that should continue running independently. + /// + /// + public bool CreateNewProcessGroup { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The application to start. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when cannot be resolved to an existing file. + public ProcessStartOptions(string fileName) + { + ArgumentException.ThrowIfNullOrEmpty(fileName); + + string? resolved = ResolvePath(fileName); + if (resolved == null || !File.Exists(resolved)) + { + throw new FileNotFoundException(SR.FileNotFoundResolvePath, fileName); + } + _fileName = resolved; + } + + internal static string? ResolvePath(string filename) + { + // If the filename is a complete path, use it, regardless of whether it exists. + if (Path.IsPathRooted(filename)) + { + // In this case, it doesn't matter whether the file exists or not; + // it's what the caller asked for, so it's what they'll get + return filename; + } + +#if WINDOWS + // From: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + // "If the file name does not contain an extension, .exe is appended. + // Therefore, if the file name extension is .com, this parameter must include the .com extension. + // If the file name ends in a period (.) with no extension, or if the file name contains a path, .exe is not appended." + + // HasExtension returns false for trailing dot, so we need to check that separately + if (filename[filename.Length - 1] != '.' + && string.IsNullOrEmpty(Path.GetDirectoryName(filename)) + && !Path.HasExtension(filename)) + { + filename += ".exe"; + } +#endif + + // Then check the executable's directory + string? path = System.Environment.ProcessPath; + if (path != null) + { + try + { + path = Path.Combine(Path.GetDirectoryName(path)!, filename); + if (File.Exists(path)) + { + return path; + } + } + catch (ArgumentException) { } // ignore any errors in data that may come from the exe path + } + + // Then check the current directory + path = Path.Combine(Directory.GetCurrentDirectory(), filename); + if (File.Exists(path)) + { + return path; + } + +#if WINDOWS + // Windows-specific search locations (from CreateProcessW documentation) + + // Check the system directory (e.g., System32) + path = GetSystemDirectory(); + if (path != null) + { + path = Path.Combine(path, filename); + if (File.Exists(path)) + { + return path; + } + } + + // Check the Windows directory + path = GetWindowsDirectory(); + if (path != null) + { + // Check the legacy System subdirectory of Windows directory (for compatibility) + string systemPath = Path.Combine(path, "System", filename); + if (File.Exists(systemPath)) + { + return systemPath; + } + + // Check the Windows directory itself + path = Path.Combine(path, filename); + if (File.Exists(path)) + { + return path; + } + } +#endif + + return FindProgramInPath(filename); + } + + internal static string? FindProgramInPath(string program) + { + string? pathEnvVar = System.Environment.GetEnvironmentVariable("PATH"); + if (pathEnvVar != null) + { + char pathSeparator = OperatingSystem.IsWindows() ? ';' : ':'; + var pathParser = new StringParser(pathEnvVar, pathSeparator, skipEmpty: true); + while (pathParser.MoveNext()) + { + string subPath = pathParser.ExtractCurrent(); + string path = Path.Combine(subPath, program); +#if WINDOWS + if (File.Exists(path)) +#else + if (Process.IsExecutable(path)) +#endif + { + return path; + } + } + } + + return null; + } + +#if WINDOWS + private static string? s_cachedSystemDirectory; + private static string? s_cachedWindowsDirectory; + + private static string? GetSystemDirectory() + { + if (s_cachedSystemDirectory == null) + { + Span buffer = stackalloc char[260]; // MAX_PATH + uint length = Interop.Kernel32.GetSystemDirectoryW(ref MemoryMarshal.GetReference(buffer), (uint)buffer.Length); + if (length > 0 && length < buffer.Length) + { + s_cachedSystemDirectory = new string(buffer.Slice(0, (int)length)); + } + } + return s_cachedSystemDirectory; + } + + private static string? GetWindowsDirectory() + { + if (s_cachedWindowsDirectory == null) + { + Span buffer = stackalloc char[260]; // MAX_PATH + uint length = Interop.Kernel32.GetWindowsDirectoryW(ref MemoryMarshal.GetReference(buffer), (uint)buffer.Length); + if (length > 0 && length < buffer.Length) + { + s_cachedWindowsDirectory = new string(buffer.Slice(0, (int)length)); + } + } + return s_cachedWindowsDirectory; + } +#endif + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Unix.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Unix.cs new file mode 100644 index 00000000000000..9aae9937f37f7d --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Unix.cs @@ -0,0 +1,157 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Diagnostics.Tests +{ + [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD | TestPlatforms.OSX)] + public partial class ProcessStartOptionsTests + { + [Fact] + public void Constructor_ResolvesShOnUnix() + { + ProcessStartOptions options = new("sh"); + Assert.True(File.Exists(options.FileName)); + // Verify the resolved path ends with "sh" (could be /bin/sh, /usr/bin/sh, etc.) + Assert.EndsWith("sh", options.FileName); + } + + [Fact] + public void ResolvePath_FindsInPath() + { + // sh should be findable in PATH on all Unix systems + ProcessStartOptions options = new("sh"); + Assert.True(File.Exists(options.FileName)); + // Verify the resolved path ends with "sh" (could be /bin/sh, /usr/bin/sh, etc.) + Assert.EndsWith("sh", options.FileName); + } + + [Fact] + public void ResolvePath_DoesNotAddExeExtension() + { + // On Unix, no .exe extension should be added + ProcessStartOptions options = new("sh"); + Assert.False(options.FileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ResolvePath_UsesCurrentDirectory() + { + string tempDir = Path.GetTempPath(); + string fileName = "testscript.sh"; + string fullPath = Path.Combine(tempDir, fileName); + + string oldDir = Directory.GetCurrentDirectory(); + try + { + File.WriteAllText(fullPath, "#!/bin/sh\necho test"); + // Make it executable + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + Directory.SetCurrentDirectory(tempDir); + ProcessStartOptions options = new(fileName); + // Use Path.GetFullPath on both sides to handle symlinks (e.g., /tmp -> /private/tmp on macOS) + Assert.Equal(Path.GetFullPath(fullPath), Path.GetFullPath(options.FileName)); + } + finally + { + Directory.SetCurrentDirectory(oldDir); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + } + } + + [Fact] + public void ResolvePath_PathSeparatorIsColon() + { + // Create a temp directory and file + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + string fileName = "testscript"; + string fullPath = Path.Combine(tempDir, fileName); + + string oldPath = Environment.GetEnvironmentVariable("PATH"); + try + { + File.WriteAllText(fullPath, "#!/bin/sh\necho test"); + // Make it executable + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + // Add temp directory to PATH using colon separator + Environment.SetEnvironmentVariable("PATH", tempDir + ":" + oldPath); + ProcessStartOptions options = new(fileName); + Assert.Equal(Path.GetFullPath(fullPath), options.FileName); + } + finally + { + Environment.SetEnvironmentVariable("PATH", oldPath); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void ResolvePath_AbsolutePathIsNotModified() + { + string tempFile = Path.GetTempFileName(); + try + { + ProcessStartOptions options = new(tempFile); + Assert.Equal(tempFile, options.FileName); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Theory] + [InlineData("ls")] + [InlineData("cat")] + [InlineData("echo")] + [InlineData("sh")] + public void ResolvePath_FindsCommonUtilities(string utilName) + { + ProcessStartOptions options = new(utilName); + Assert.True(File.Exists(options.FileName), $"{utilName} should be found and exist"); + Assert.Contains(utilName, options.FileName); + } + + [Fact] + public void ResolvePath_RejectsDirectories() + { + // Create a directory with executable permissions + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + string oldDir = Directory.GetCurrentDirectory(); + try + { + // Try to use the directory name as a command + Directory.SetCurrentDirectory(Path.GetTempPath()); + Assert.Throws(() => new ProcessStartOptions(Path.GetFileName(tempDir))); + } + finally + { + Directory.SetCurrentDirectory(oldDir); + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir); + } + } + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Windows.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Windows.cs new file mode 100644 index 00000000000000..1600c71e5ebfb4 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Windows.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace System.Diagnostics.Tests +{ + public partial class ProcessStartOptionsTests + { + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))] + public void Constructor_ResolvesCmdOnWindows() + { + ProcessStartOptions options = new("cmd"); + Assert.EndsWith("cmd.exe", options.FileName); + Assert.True(File.Exists(options.FileName)); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer), nameof(PlatformDetection.IsNotWindowsServerCore))] + public void ResolvePath_AddsExeExtension() + { + // Test that .exe is appended when no extension is provided + ProcessStartOptions options = new("notepad"); + Assert.EndsWith(".exe", options.FileName, StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(options.FileName)); + } + + [Fact] + public void ResolvePath_DoesNotAddExeExtensionForTrailingDot() + { + // "If the file name ends in a period (.) with no extension, .exe is not appended." + // This should fail since "notepad." won't exist + Assert.Throws(() => new ProcessStartOptions("notepad.")); + } + + [Fact] + public void ResolvePath_PreservesComExtension() + { + // The .com extension should be preserved + string fileName = "test.com"; + string tempDir = Path.GetTempPath(); + string fullPath = Path.Combine(tempDir, fileName); + + string oldDir = Directory.GetCurrentDirectory(); + try + { + File.WriteAllText(fullPath, "test"); + Directory.SetCurrentDirectory(tempDir); + ProcessStartOptions options = new(fileName); + Assert.EndsWith(".com", options.FileName, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.SetCurrentDirectory(oldDir); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))] + public void ResolvePath_FindsInSystemDirectory() + { + // cmd.exe should be found in system directory + ProcessStartOptions options = new("cmd"); + Assert.True(File.Exists(options.FileName)); + string systemDirectory = Environment.SystemDirectory; + Assert.StartsWith(systemDirectory, options.FileName, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer), nameof(PlatformDetection.IsNotWindowsServerCore))] + public void ResolvePath_FindsInWindowsDirectory() + { + ProcessStartOptions options = new("notepad"); + Assert.True(File.Exists(options.FileName)); + } + + [Fact] + public void ResolvePath_UsesCurrentDirectory() + { + string tempDir = Path.GetTempPath(); + string fileName = "testapp.exe"; + string fullPath = Path.Combine(tempDir, fileName); + + string oldDir = Directory.GetCurrentDirectory(); + try + { + File.WriteAllText(fullPath, "test"); + Directory.SetCurrentDirectory(tempDir); + ProcessStartOptions options = new(fileName); + Assert.Equal(fullPath, options.FileName); + } + finally + { + Directory.SetCurrentDirectory(oldDir); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + } + } + + [Fact] + public void ResolvePath_PathSeparatorIsSemicolon() + { + // Create a temp directory and file + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + string fileName = "testexe.exe"; + string fullPath = Path.Combine(tempDir, fileName); + + string oldPath = Environment.GetEnvironmentVariable("PATH"); + try + { + File.WriteAllText(fullPath, "test"); + Environment.SetEnvironmentVariable("PATH", tempDir + ";" + oldPath); + ProcessStartOptions options = new("testexe"); + Assert.Equal(fullPath, options.FileName); + } + finally + { + Environment.SetEnvironmentVariable("PATH", oldPath); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public void ResolvePath_AbsolutePathIsNotModified() + { + string tempFile = Path.GetTempFileName(); + try + { + // Rename to remove extension to test that .exe is not added for absolute paths + string noExtFile = Path.ChangeExtension(tempFile, null); + File.Move(tempFile, noExtFile); + tempFile = noExtFile; + + ProcessStartOptions options = new(tempFile); + Assert.Equal(tempFile, options.FileName); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.cs new file mode 100644 index 00000000000000..75b361e3337877 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace System.Diagnostics.Tests +{ + public partial class ProcessStartOptionsTests + { + [Fact] + public void Constructor_NullFileName_Throws() + { + Assert.Throws(() => new ProcessStartOptions(null)); + } + + [Fact] + public void Constructor_EmptyFileName_Throws() + { + Assert.Throws(() => new ProcessStartOptions(string.Empty)); + } + + [Fact] + public void Constructor_NonExistentFile_Throws() + { + string nonExistentFile = "ThisFileDoesNotExist_" + Guid.NewGuid().ToString(); + Assert.Throws(() => new ProcessStartOptions(nonExistentFile)); + } + + [Fact] + public void Constructor_WithAbsolutePath() + { + string tempFile = Path.GetTempFileName(); + try + { + ProcessStartOptions options = new(tempFile); + Assert.Equal(tempFile, options.FileName); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void Arguments_DefaultIsEmpty() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + IList args = options.Arguments; + Assert.NotNull(args); + Assert.Empty(args); + } + + [Fact] + public void Arguments_CanAddAndModify() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + options.Arguments.Add("arg1"); + options.Arguments.Add("arg2"); + Assert.Equal(2, options.Arguments.Count); + Assert.Equal("arg1", options.Arguments[0]); + Assert.Equal("arg2", options.Arguments[1]); + + options.Arguments = new List { "newArg" }; + Assert.Single(options.Arguments); + Assert.Equal("newArg", options.Arguments[0]); + } + + [Fact] + public void Environment_CanAddAndModify() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + IDictionary env = options.Environment; + + int originalCount = env.Count; + env["TestKey1"] = "TestValue1"; + env["TestKey2"] = "TestValue2"; + Assert.Equal(originalCount + 2, env.Count); + Assert.Equal("TestValue1", env["TestKey1"]); + Assert.Equal("TestValue2", env["TestKey2"]); + + env.Remove("TestKey1"); + Assert.Equal(originalCount + 1, env.Count); + Assert.False(env.ContainsKey("TestKey1")); + } + + [Fact] + public void Environment_CaseSensitivityIsPlatformSpecific() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + IDictionary env = options.Environment; + + env["TestKey"] = "TestValue"; + + if (OperatingSystem.IsWindows()) + { + Assert.True(env.ContainsKey("testkey")); + Assert.Equal("TestValue", env["TESTKEY"]); + } + else + { + Assert.False(env.ContainsKey("testkey")); + } + } + + [Fact] + public void InheritedHandles_DefaultIsEmpty() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + IList handles = options.InheritedHandles; + Assert.NotNull(handles); + Assert.Empty(handles); + } + + [Fact] + public void InheritedHandles_CanSet() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + List newHandles = new List(); + options.InheritedHandles = newHandles; + Assert.Same(newHandles, options.InheritedHandles); + } + + [Fact] + public void WorkingDirectory_DefaultIsNull() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + Assert.Null(options.WorkingDirectory); + } + + [Fact] + public void WorkingDirectory_CanSet() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + string tempDir = Path.GetTempPath(); + options.WorkingDirectory = tempDir; + Assert.Equal(tempDir, options.WorkingDirectory); + } + + [Fact] + public void KillOnParentExit_DefaultIsFalse() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + Assert.False(options.KillOnParentExit); + } + + [Fact] + public void KillOnParentExit_CanSet() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + options.KillOnParentExit = true; + Assert.True(options.KillOnParentExit); + } + + [Fact] + public void CreateNewProcessGroup_DefaultIsFalse() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + Assert.False(options.CreateNewProcessGroup); + } + + [Fact] + public void CreateNewProcessGroup_CanSet() + { + ProcessStartOptions options = new(GetCurrentProcessName()); + options.CreateNewProcessGroup = true; + Assert.True(options.CreateNewProcessGroup); + } + + private string GetCurrentProcessName() + { + return Environment.ProcessPath ?? (OperatingSystem.IsWindows() + ? Path.Combine(Environment.SystemDirectory, "cmd.exe") + : "/bin/sh"); + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj b/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj index 93cb8d34b091e1..63523ef48c86fb 100644 --- a/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj +++ b/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj @@ -28,6 +28,7 @@ + @@ -40,6 +41,7 @@ + +