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..0e571262f1ede0 100644 --- a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj +++ b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj @@ -26,6 +26,7 @@ + @@ -47,6 +48,8 @@ Link="Common\System\Text\ValueStringBuilder.cs" /> + @@ -235,8 +238,6 @@ - + /// 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 = FindProgramInPath(filename); + requiresExistenceCheck = fromPath is null; + return fromPath; + } + + private 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); + if (File.Exists(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