Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ class Program
options: new()
{
WorkingDirectory = AbsoluteDirectoryPath.Create(@"C:\repos\my project"),
EnvironmentVariables = new Dictionary<string, string?>
{
["GIT_TERMINAL_PROMPT"] = "0",
["LC_ALL"] = "C",
},
});

Console.WriteLine($"Process exited with code: {exitCode}");
Expand All @@ -172,6 +177,19 @@ class Program

Without a `WorkingDirectory` the process inherits the current directory of the calling process, which is what commands did before this option existed.

`EnvironmentVariables` is an overlay on the inherited environment, not a replacement: a name you do not list keeps whatever the calling process had. A `null` value removes a variable, which is how you unset something the parent had set:

```csharp
EnvironmentVariables = new Dictionary<string, string?>
{
["GIT_DIR"] = null,
}
```

Environment variables are the only control surface some tools expose, so this covers behaviour with no command-line equivalent — `GIT_TERMINAL_PROMPT=0` to make an authenticating `git fetch` fail rather than block forever on a prompt no terminal will answer, `GIT_ASKPASS`/`SSH_ASKPASS` to supply credentials without putting them on a command line where any process listing can read them, and `LC_ALL=C` to force stable, machine-parseable output rather than whatever the host locale produces.

> **_NOTE:_** _`EnvironmentVariables` cannot be combined with `Elevation.Elevated` on Windows. Elevation requires `UseShellExecute`, which offers nowhere to pass an environment, so the call throws `ArgumentException` rather than silently dropping the variables._

The type is `AbsoluteDirectoryPath` rather than a string on purpose. A relative directory would have to be resolved against the caller's current directory — the process-global state this option exists to avoid depending on, since it is shared by every thread and races with concurrent calls.

`CommandOptions.Elevation` carries the privilege level, so a single options object replaces the separate `Elevation` argument.
Expand Down Expand Up @@ -219,6 +237,7 @@ class Program
### CommandOptions Record

- `WorkingDirectory`: An `AbsoluteDirectoryPath` naming the directory the process starts in, or `null` to inherit the caller's current directory.
- `EnvironmentVariables`: An `IReadOnlyDictionary<string, string?>` applied over the inherited environment, or `null` to inherit it unchanged. A `null` value removes a variable.
- `Elevation`: The privilege level under which to run the command. Defaults to `Elevation.Default`.

### Elevation Enum
Expand Down
123 changes: 123 additions & 0 deletions RunCommand.Test/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,35 @@
private static string CreateDirectoryForTest([CallerMemberName] string caller = "") =>
Directory.CreateDirectory(Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)} {caller}")).FullName;

/// <summary>
/// Returns a command that prints the value of an environment variable, wrapped in brackets so
/// that an empty value is still distinguishable from no output at all.
/// </summary>
private static (string FileName, string[] Arguments) GetPrintEnvironmentVariableCommand(string name) =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? ("cmd", ["/c", $"echo [%{name}%]"])
: ("sh", ["-c", $"echo \"[${name}]\""]);

// Tests run in parallel and the host environment is process-wide, so each test needs its own
// variable name to avoid stepping on another test's value.
private static string EnvironmentVariableNameFor([CallerMemberName] string caller = "") =>
$"RUNCOMMAND_TEST_{caller.ToUpperInvariant()}";

private static async Task<string> ReadEnvironmentVariableFromChildAsync(string name, CommandOptions options)
{
(string fileName, string[] arguments) = GetPrintEnvironmentVariableCommand(name);
List<string> output = [];

int exitCode = await RunCommand.ExecuteAsync(
fileName,
arguments,
new LineOutputHandler(onStandardOutput: output.Add),
options).ConfigureAwait(false);

Assert.AreEqual(0, exitCode, "Expected the command to run successfully.");
return string.Concat(output).Trim();
}

/// <summary>
/// Returns a command that runs for long enough to be cancelled mid-flight.
/// </summary>
Expand Down Expand Up @@ -477,4 +506,98 @@
await Assert.ThrowsAsync<ArgumentNullException>(
() => RunCommand.ExecuteAsync("dotnet", ["--version"], new OutputHandler(), null!)).ConfigureAwait(false);
}
[TestMethod]
public async Task ExecuteAsyncShouldSetAnEnvironmentVariableForTheChildProcess()
{
string name = EnvironmentVariableNameFor();

string reported = await ReadEnvironmentVariableFromChildAsync(
name,
new CommandOptions { EnvironmentVariables = new Dictionary<string, string?> { [name] = "expected" } }).ConfigureAwait(false);

Assert.AreEqual("[expected]", reported, "Expected the child to see the variable that was set for it.");
}

[TestMethod]
public async Task ExecuteAsyncShouldOverrideAnInheritedEnvironmentVariable()
{
string name = EnvironmentVariableNameFor();
Environment.SetEnvironmentVariable(name, "inherited");

try
{
string reported = await ReadEnvironmentVariableFromChildAsync(
name,
new CommandOptions { EnvironmentVariables = new Dictionary<string, string?> { [name] = "override" } }).ConfigureAwait(false);

Assert.AreEqual("[override]", reported, "Expected the overlay to win over the inherited value.");
}
finally
{
Environment.SetEnvironmentVariable(name, null);
}
}

[TestMethod]
public async Task ExecuteAsyncShouldRemoveAnInheritedEnvironmentVariableWhenTheValueIsNull()
{
string name = EnvironmentVariableNameFor();
Environment.SetEnvironmentVariable(name, "inherited");

try
{
string reported = await ReadEnvironmentVariableFromChildAsync(
name,
new CommandOptions { EnvironmentVariables = new Dictionary<string, string?> { [name] = null } }).ConfigureAwait(false);

// An unset variable prints differently per shell -- cmd echoes the name back verbatim,
// sh prints nothing -- so this pins the part that matters on both: the inherited value
// did not reach the child.
Assert.DoesNotContain("inherited", reported, "Expected a null value to remove the inherited variable.");
}
finally
{
Environment.SetEnvironmentVariable(name, null);
}
}

[TestMethod]
public async Task ExecuteAsyncShouldInheritTheEnvironmentWhenNoVariablesAreGiven()
{
string name = EnvironmentVariableNameFor();
Environment.SetEnvironmentVariable(name, "inherited");

try
{
string reported = await ReadEnvironmentVariableFromChildAsync(name, new CommandOptions()).ConfigureAwait(false);

Assert.AreEqual("[inherited]", reported, "Expected an unset overlay to leave the previous behaviour untouched.");
}
finally
{
Environment.SetEnvironmentVariable(name, null);
}
}

[TestMethod]
public async Task ExecuteAsyncShouldRejectEnvironmentVariablesCombinedWithElevation()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Assert.Inconclusive("Elevation only changes how the process is started on Windows.");
}

Check warning on line 588 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZlFS-ePsaw6URAlFD&open=AaAZlFS-ePsaw6URAlFD&pullRequest=44

// Elevation forces UseShellExecute, which cannot carry an environment. Failing loudly beats
// silently dropping variables the caller may be relying on.
await Assert.ThrowsAsync<ArgumentException>(
() => RunCommand.ExecuteAsync(
"cmd",
["/c", "exit 0"],
new OutputHandler(),
new CommandOptions
{
Elevation = Elevation.Elevated,
EnvironmentVariables = new Dictionary<string, string?> { ["ANY"] = "value" },
})).ConfigureAwait(false);

Check warning on line 601 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZlFS-ePsaw6URAlFC&open=AaAZlFS-ePsaw6URAlFC&pullRequest=44
}
}
13 changes: 13 additions & 0 deletions RunCommand/CommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace ktsu.RunCommand;

using System.Collections.Generic;
using ktsu.Semantics.Paths;

/// <summary>
Expand All @@ -24,6 +25,18 @@ public sealed record CommandOptions
/// </remarks>
public AbsoluteDirectoryPath? WorkingDirectory { get; init; }

/// <summary>
/// Gets the environment variables to apply over the inherited environment, or
/// <see langword="null"/> to inherit the calling process's environment unchanged.
/// </summary>
/// <remarks>
/// The entries are an overlay rather than a replacement: a name not listed here keeps whatever
/// the calling process had. A <see langword="null"/> value removes a variable, matching the
/// semantics of <see cref="System.Diagnostics.ProcessStartInfo.Environment"/>, which is how a
/// caller unsets something the parent had set.
/// </remarks>
public IReadOnlyDictionary<string, string?>? EnvironmentVariables { get; init; }

/// <summary>
/// Gets the privilege level under which to run the command.
/// </summary>
Expand Down
26 changes: 26 additions & 0 deletions RunCommand/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,22 @@
return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false);
}

private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation)

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 296 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check failure on line 296 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZlFX5ePsaw6URAlFE&open=AaAZlFX5ePsaw6URAlFE&pullRequest=44
{
bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
useElevation = options.Elevation == Elevation.Elevated && isWindows;

if (useElevation && options.EnvironmentVariables is not null)
{
// Elevation needs UseShellExecute, which starts the process through the shell and offers
// nowhere to put an environment. Saying so here beats letting Process.Start fail with a
// message that does not mention either setting, and beats silently dropping variables a
// caller may be relying on for credentials or machine-parseable output.
throw new ArgumentException(
"Environment variables cannot be set for an elevated command, because elevation requires UseShellExecute.",
nameof(options));
}

ProcessStartInfo startInfo = new()
{
FileName = fileName,
Expand All @@ -322,6 +333,21 @@
startInfo.StandardErrorEncoding = outputHandler.Encoding;
startInfo.UseShellExecute = false;

if (options.EnvironmentVariables is not null)
{
foreach (KeyValuePair<string, string?> variable in options.EnvironmentVariables)
{
if (variable.Value is null)
{
_ = startInfo.Environment.Remove(variable.Key);
}
else
{
startInfo.Environment[variable.Key] = variable.Value;
}
}
}

if (isWindows)
{
startInfo.LoadUserProfile = true;
Expand Down