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..14f9a5529cfdf8 100644 --- a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj +++ b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-freebsd;$(NetCoreAppCurrent)-linux;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-maccatalyst;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-tvos;$(NetCoreAppCurrent)-illumos;$(NetCoreAppCurrent)-solaris;$(NetCoreAppCurrent) @@ -26,6 +26,8 @@ + + @@ -47,6 +49,8 @@ Link="Common\System\Text\ValueStringBuilder.cs" /> + @@ -225,6 +229,7 @@ + @@ -235,8 +240,7 @@ - + allowedProgramsToRun = ["xdg-open", "gnome-open", "kfmclient"]; foreach (var program in allowedProgramsToRun) { - string? pathToProgram = FindProgramInPath(program); + string? pathToProgram = ProcessUtils.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..143e962984b0cf 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 ProcessUtils.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..f26a8f70ffba74 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 @@ -663,7 +663,7 @@ private static string[] CreateEnvp(ProcessStartInfo psi) // find filename on PATH else { - resolvedFilename = FindProgramInPath(filename); + resolvedFilename = ProcessUtils.FindProgramInPath(filename); } } @@ -726,93 +726,7 @@ private static string[] CreateEnvp(ProcessStartInfo psi) } // 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) - { - Interop.Sys.FileStatus fileinfo; - - if (Interop.Sys.Stat(fullPath, out fileinfo) < 0) - { - return false; - } - - // Check if the path is a directory. - if ((fileinfo.Mode & Interop.Sys.FileTypes.S_IFMT) == Interop.Sys.FileTypes.S_IFDIR) - { - return false; - } - - const UnixFileMode AllExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - - UnixFileMode permissions = ((UnixFileMode)fileinfo.Mode) & AllExecute; - - // Avoid checking user/group when permission. - if (permissions == AllExecute) - { - return true; - } - else if (permissions == 0) - { - return false; - } - - uint euid = Interop.Sys.GetEUid(); - - if (euid == 0) - { - return true; // We're root. - } - - if (euid == fileinfo.Uid) - { - // We own the file. - return (permissions & UnixFileMode.UserExecute) != 0; - } - - bool groupCanExecute = (permissions & UnixFileMode.GroupExecute) != 0; - bool otherCanExecute = (permissions & UnixFileMode.OtherExecute) != 0; - - // Avoid group check when group and other have same permissions. - if (groupCanExecute == otherCanExecute) - { - return groupCanExecute; - } - - if (Interop.Sys.IsMemberOfGroup(fileinfo.Gid)) - { - return groupCanExecute; - } - else - { - return otherCanExecute; - } + return ProcessUtils.FindProgramInPath(filename); } private static long s_ticksPerSecond; 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..acac5b0c546375 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartOptions.cs @@ -0,0 +1,255 @@ +// 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 absolute path of the application to start. + /// + /// + /// The absolute path to the executable file. This path is resolved from the fileName parameter + /// passed to the constructor by searching through various directories if needed. + /// + /// + /// + /// The path is "resolved" meaning it has been converted to an absolute path and verified to exist. + /// + /// + /// See for complete details on the resolution process. + /// + /// + 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. + /// is . + /// is empty. + /// cannot be resolved to an existing file. + /// + /// + /// The is resolved to an absolute path. + /// + /// + /// When the is a fully qualified path, it is used as-is without any resolution. + /// + /// + /// When the is a rooted but not fully qualified path (for example, C:foo.exe or \foo\bar.exe on Windows), + /// it is resolved to an absolute path using the current directory context. + /// + /// + /// When the is an explicit relative path containing directory separators (for example, .\foo.exe or ../bar), + /// it is resolved relative to the current directory. + /// + /// + /// When the is a bare filename without directory separators, the system searches for the executable in the following locations: + /// + /// + /// On Windows: + /// + /// + /// The System directory (for example, C:\Windows\System32). + /// The directories listed in the PATH environment variable. + /// + /// + /// On Unix: + /// + /// + /// The directories listed in the PATH environment variable. + /// + /// + /// On Windows, if the does not have an extension and does not contain directory separators, .exe is appended before searching. + /// + /// + public ProcessStartOptions(string fileName) + { + ArgumentException.ThrowIfNullOrEmpty(fileName); + + // The file could be deleted or replaced after this check and before the process is started (TOCTOU). + // In such case, the process creation will fail. + // We resolve the path here to provide unified error handling and to avoid + // starting a process that will fail immediately after creation. + string? resolved = ResolvePath(fileName, out bool requiresExistenceCheck); + if (resolved is null || (requiresExistenceCheck && !File.Exists(resolved))) + { + throw new FileNotFoundException(SR.FileNotFoundResolvePath, fileName); + } + _fileName = resolved; + } + + // There are two ways to create a process on Windows using CreateProcess sys-call: + // 1. With NULL lpApplicationName and non-NULL lpCommandLine, where the first token of the + // command line is the executable name. In this case, the system will resolve the executable + // name to an actual file on disk using an algorithm that is not fully documented. + // 2. With non-NULL lpApplicationName, where the system will use the provided application + // name as-is without any resolution, and the command line is passed as-is to the process. + // + // The recommended way is to use the second approach and provide the resolved executable path. + // + // Changing the resolution logic for existing Process APIs would introduce breaking changes. + // Since we are introducing a new API, we take it as an opportunity to clean up the legacy baggage + // to have simpler, easier to understand and more secure filename resolution algorithm + // that is more consistent across OSes and aligned with other modern platforms. + private static string? ResolvePath(string filename, out bool requiresExistenceCheck) + { + Debug.Assert(!string.IsNullOrEmpty(filename), "Caller should have validated the filename."); + requiresExistenceCheck = true; + + if (Path.IsPathFullyQualified(filename)) + { + return filename; + } + + // Check for filenames that are not bare filenames. It includes: + // - Relative paths with directory separators (e.g., .\foo.exe, ..\foo.exe, subdir\foo.exe) + // - Rooted but not fully qualified paths (e.g., C:foo.exe, \foo.exe on Windows) + if (Path.GetFileName(filename.AsSpan()).Length != filename.Length) + { + return Path.GetFullPath(filename); // Resolve to absolute path + } + + // We want to keep the resolution logic in one place for better maintainability and consistency. + // That is why we don't provide platform-specific implementations files. + if (OperatingSystem.IsWindows()) + { + // 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] != '.' && !Path.HasExtension(filename)) + { + filename += ".exe"; + } + + // Windows-specific search location: the system directory (e.g., C:\Windows\System32) + string path = Path.Combine(System.Environment.SystemDirectory, filename); + if (File.Exists(path)) + { + requiresExistenceCheck = false; + return path; + } + } + + string? fromPath = ProcessUtils.FindProgramInPath(filename); + requiresExistenceCheck = fromPath is null; + return fromPath; + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.Unix.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.Unix.cs new file mode 100644 index 00000000000000..eb4c06a033566e --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.Unix.cs @@ -0,0 +1,72 @@ +// 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; + +namespace System.Diagnostics +{ + internal static partial class ProcessUtils + { + private static bool IsExecutable(string fullPath) + { + Interop.Sys.FileStatus fileinfo; + + if (Interop.Sys.Stat(fullPath, out fileinfo) < 0) + { + return false; + } + + // Check if the path is a directory. + if ((fileinfo.Mode & Interop.Sys.FileTypes.S_IFMT) == Interop.Sys.FileTypes.S_IFDIR) + { + return false; + } + + const UnixFileMode AllExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + + UnixFileMode permissions = ((UnixFileMode)fileinfo.Mode) & AllExecute; + + // Avoid checking user/group when permission. + if (permissions == AllExecute) + { + return true; + } + else if (permissions == 0) + { + return false; + } + + uint euid = Interop.Sys.GetEUid(); + + if (euid == 0) + { + return true; // We're root. + } + + if (euid == fileinfo.Uid) + { + // We own the file. + return (permissions & UnixFileMode.UserExecute) != 0; + } + + bool groupCanExecute = (permissions & UnixFileMode.GroupExecute) != 0; + bool otherCanExecute = (permissions & UnixFileMode.OtherExecute) != 0; + + // Avoid group check when group and other have same permissions. + if (groupCanExecute == otherCanExecute) + { + return groupCanExecute; + } + + if (Interop.Sys.IsMemberOfGroup(fileinfo.Gid)) + { + return groupCanExecute; + } + else + { + return otherCanExecute; + } + } + + } +} diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.Windows.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.Windows.cs new file mode 100644 index 00000000000000..6208deff5d094d --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.Windows.cs @@ -0,0 +1,15 @@ +// 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; + +namespace System.Diagnostics +{ + internal static partial class ProcessUtils + { + private static bool IsExecutable(string fullPath) + { + return File.Exists(fullPath); + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.cs new file mode 100644 index 00000000000000..c3ead1de0030bf --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessUtils.cs @@ -0,0 +1,32 @@ +// 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; + +namespace System.Diagnostics +{ + internal static partial class ProcessUtils + { + internal static string? FindProgramInPath(string program) + { + string? pathEnvVar = System.Environment.GetEnvironmentVariable("PATH"); + if (pathEnvVar is not null) + { + StringParser pathParser = new(pathEnvVar, Path.PathSeparator, skipEmpty: true); + while (pathParser.MoveNext()) + { + string subPath = pathParser.ExtractCurrent(); + string path = Path.Combine(subPath, program); + // On Unix, we need to verify the file has execute permissions. + // On Windows, any file that exists is considered executable. + if (IsExecutable(path)) + { + return path; + } + } + } + + return null; + } + } +} 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..a0c5a3b2325262 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Unix.cs @@ -0,0 +1,169 @@ +// 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)); + } + + [Theory] + [InlineData("./testscript.sh", true)] + [InlineData("testscript.sh", false)] + public void ResolvePath_UsesCurrentDirectory(string fileNameFormat, bool shouldSucceed) + { + 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); + + if (shouldSucceed) + { + ProcessStartOptions options = new(fileNameFormat); + Assert.True(File.Exists(options.FileName)); + // on macOS, we need to handle /tmp/testscript.sh -> /private/tmp/testscript.sh + Assert.EndsWith(fullPath, options.FileName); + } + else + { + // Without ./ prefix, should not find file in CWD and should throw + Assert.Throws(() => new ProcessStartOptions(fileNameFormat)); + } + } + 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.EndsWith(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..df2dabdae7624d --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.Windows.cs @@ -0,0 +1,199 @@ +// 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 +{ + 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.Ordinal); + } + 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 expectedPath = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + Assert.Equal(expectedPath, options.FileName); + } + + [Theory] + [InlineData(".\\testapp.exe", true)] + [InlineData("testapp.exe", false)] + public void ResolvePath_UsesCurrentDirectory(string fileNameFormat, bool shouldSucceed) + { + 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); + + if (shouldSucceed) + { + ProcessStartOptions options = new(fileNameFormat); + Assert.Equal(fullPath, options.FileName); + } + else + { + // Without .\ prefix, should not find file in CWD and should throw + Assert.Throws(() => new ProcessStartOptions(fileNameFormat)); + } + } + 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); + } + } + } + + [Fact] + public void ResolvePath_RootedButNotFullyQualifiedPath() + { + // Test paths like "C:foo.exe" (without backslash after colon) which are rooted but not fully qualified + // These resolve relative to the current directory on that drive + string tempDir = Path.GetTempPath(); + string fileName = "test_rooted.tmp"; + string fullPath = Path.Combine(tempDir, fileName); + + string oldDir = Directory.GetCurrentDirectory(); + try + { + File.WriteAllText(fullPath, "test"); + Directory.SetCurrentDirectory(tempDir); + + // Create a rooted but not fully qualified path: "C:filename" (no backslash after drive) + string drive = Path.GetPathRoot(tempDir)!.TrimEnd('\\', '/'); // e.g., "C:" + string rootedPath = $"{drive}{fileName}"; // e.g., "C:test_rooted.tmp" + + Assert.True(Path.IsPathRooted(rootedPath)); + Assert.False(Path.IsPathFullyQualified(rootedPath)); + + ProcessStartOptions options = new(rootedPath); + + Assert.True(Path.IsPathFullyQualified(options.FileName)); + Assert.Equal(fullPath, options.FileName); + } + finally + { + Directory.SetCurrentDirectory(oldDir); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + } + } + } +} 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..ed202b5d22648c --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartOptionsTests.cs @@ -0,0 +1,179 @@ +// 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 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 = []; + 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 @@ + + diff --git a/src/libraries/System.Private.CoreLib/src/System/Environment.Windows.cs b/src/libraries/System.Private.CoreLib/src/System/Environment.Windows.cs index 2f251a11a2970e..80624afcb23a44 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Environment.Windows.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Environment.Windows.cs @@ -155,25 +155,26 @@ private static unsafe OperatingSystem GetOSVersion() new OperatingSystem(PlatformID.Win32NT, version); } - public static string SystemDirectory - { - get - { - // Normally this will be C:\Windows\System32 - var builder = new ValueStringBuilder(stackalloc char[32]); + private static string? s_systemDirectory; - uint length; - while ((length = Interop.Kernel32.GetSystemDirectoryW(ref builder.GetPinnableReference(), (uint)builder.Capacity)) > builder.Capacity) - { - builder.EnsureCapacity((int)length); - } + public static string SystemDirectory => s_systemDirectory ??= GetSystemDirectory(); - if (length == 0) - throw Win32Marshal.GetExceptionForLastWin32Error(); + private static string GetSystemDirectory() + { + // Normally this will be C:\Windows\System32 + var builder = new ValueStringBuilder(stackalloc char[32]); - builder.Length = (int)length; - return builder.ToString(); + uint length; + while ((length = Interop.Kernel32.GetSystemDirectoryW(ref builder.GetPinnableReference(), (uint)builder.Capacity)) > builder.Capacity) + { + builder.EnsureCapacity((int)length); } + + if (length == 0) + throw Win32Marshal.GetExceptionForLastWin32Error(); + + builder.Length = (int)length; + return builder.ToString(); } public static unsafe bool UserInteractive