// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using Aspire.Cli.Git;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Tests.Git;
public partial class GitCommandRunnerTests
{
[Theory]
[InlineData(nameof(GitCommandMode.Unattended), true, true)]
[InlineData(nameof(GitCommandMode.Interactive), false, true)]
[InlineData(nameof(GitCommandMode.Interactive), true, false)]
[InlineData(nameof(GitCommandMode.Captured), false, false)]
public void CreateStartInfo_AskPassPolicyFollowsEffectiveInteractivity(string mode, bool interactiveHost, bool disabled)
{
var runner = new GitCommandRunner(interactiveHost
? TestHelpers.CreateInteractiveHostEnvironment()
: TestHelpers.CreateNonInteractiveHostEnvironment(), _loggerFactory.CreateLogger<GitCommandRunner>());
var info = runner.CreateStartInfo(Environment.CurrentDirectory, ["ls-remote", "--", "repository"], Enum.Parse<GitCommandMode>(mode));
Assert.Equal(disabled
? ["--literal-pathspecs", "-c", "core.askPass=", "ls-remote", "--", "repository"]
: ["--literal-pathspecs", "ls-remote", "--", "repository"], info.ArgumentList);
info.Environment.TryGetValue("GIT_ASKPASS", out var gitAskPass);
info.Environment.TryGetValue("SSH_ASKPASS", out var sshAskPass);
info.Environment.TryGetValue("SSH_ASKPASS_REQUIRE", out var sshAskPassRequire);
Assert.Equal(disabled ? "" : Environment.GetEnvironmentVariable("GIT_ASKPASS"), gitAskPass);
Assert.Equal(disabled ? "" : Environment.GetEnvironmentVariable("SSH_ASKPASS"), sshAskPass);
Assert.Equal(disabled ? "never" : Environment.GetEnvironmentVariable("SSH_ASKPASS_REQUIRE"), sshAskPassRequire);
Assert.Equal(disabled || mode == nameof(GitCommandMode.Captured), info.RedirectStandardInput);
}
[Theory]
[InlineData("git", true)]
[InlineData("core", true)]
[InlineData("ssh", true)]
[InlineData("git", false)]
[InlineData("core", false)]
[InlineData("ssh", false)]
[RequiresTools(["git"])]
public async Task RunProcess_RealGit401DisablesAskPassFallbacksButCapturedModePreservesThem(string mechanism, bool unattended)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await using var server = new GitAuthenticationServer();
var marker = Path.Combine(workspace.Path, "askpass-called");
var helper = Path.Combine(workspace.Path, "askpass.sh");
// Synthetic credentials exercise callbacks only; the server always rejects them.
await File.WriteAllTextAsync(helper, $"#!/bin/sh\nprintf called >> '{ShellPath(marker)}'\nprintf probe-only\n");
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(helper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
var helperCommand = helper;
var config = Path.Combine(workspace.Path, "gitconfig");
await File.WriteAllTextAsync(config, "[credential]\nhelper =\n"
+ (mechanism == "core" ? $"[core]\naskPass = \"{helperCommand.Replace("\\", "\\\\", StringComparison.Ordinal)}\"\n" : ""));
var mode = unattended ? GitCommandMode.Unattended : GitCommandMode.Captured;
var runner = CreateRunner();
var info = runner.CreateStartInfo(workspace.Path, ["ls-remote", "--", server.Repository], mode);
IsolateGit(info, workspace.Path, config);
if (mechanism == "git")
{
info.Environment["GIT_ASKPASS"] = helperCommand;
}
else if (mechanism == "ssh")
{
info.Environment["SSH_ASKPASS"] = helperCommand;
}
if (unattended)
{
GitCommandRunner.DisableAskPass(info.Environment);
}
using var timeout = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(milliseconds: 15_000);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken, timeout.Token);
var exception = await Assert.ThrowsAsync<GitCommandException>(() =>
runner.RunProcessAsync(info, captureCancellationOutput: false, mode, cancellation.Token));
Assert.Equal(GitFailure.AuthenticationFailed, exception.Failure);
Assert.Equal(!unattended, File.Exists(marker));
Assert.Equal(unattended ? 1 : 2, server.Requests);
Assert.Equal("[credential]\nhelper =\n"
+ (mechanism == "core" ? $"[core]\naskPass = \"{helperCommand.Replace("\\", "\\\\", StringComparison.Ordinal)}\"\n" : ""), await File.ReadAllTextAsync(config));
}
[Fact]
[RequiresTools(["git"])]
public async Task RunProcess_RealGit401PreservesNoninteractiveCredentialHelpers()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await using var server = new GitAuthenticationServer();
var marker = Path.Combine(workspace.Path, "credential-operations");
var helper = Path.Combine(workspace.Path, "credential.sh");
await File.WriteAllTextAsync(helper,
$"#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$1\" >> '{ShellPath(marker)}'\n"
+ "if [ \"$1\" = get ]; then printf 'username=probe\\npassword=probe-only\\n\\n'; fi\n");
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(helper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
var config = Path.Combine(workspace.Path, "gitconfig");
var configuration = $"[credential]\nhelper =\nhelper = !'{ShellPath(helper)}'\n";
await File.WriteAllTextAsync(config, configuration);
var runner = CreateRunner();
var info = runner.CreateStartInfo(workspace.Path, ["ls-remote", "--", server.Repository], GitCommandMode.Unattended);
IsolateGit(info, workspace.Path, config);
GitCommandRunner.DisableAskPass(info.Environment);
using var timeout = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(milliseconds: 15_000);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken, timeout.Token);
var exception = await Assert.ThrowsAsync<GitCommandException>(() =>
runner.RunProcessAsync(info, captureCancellationOutput: false, GitCommandMode.Unattended, cancellation.Token));
Assert.Equal(GitFailure.AuthenticationFailed, exception.Failure);
Assert.Equal(["get", "erase"], await File.ReadAllLinesAsync(marker));
Assert.Equal(2, server.Requests);
Assert.Equal("never", info.Environment["GCM_INTERACTIVE"]);
Assert.Equal(configuration, await File.ReadAllTextAsync(config));
}
[Theory]
[InlineData("ssh://git@example.test/repository", true)]
[InlineData("git+ssh://example.test/repository", true)]
[InlineData("ssh+git://example.test/repository", true)]
[InlineData("git@example.test:repository", true)]
[InlineData("example.test:repository", true)]
[InlineData("[::1]:repository", true)]
[InlineData("https://example.test/repository", false)]
[InlineData("file:///repository", false)]
[InlineData("C:\\repository", false)]
[InlineData("/repository:local", false)]
[InlineData("./repository:local", false)]
public void UsesSsh_RecognizesTransportWithoutMistakingLocalPaths(string repository, bool ssh) =>
Assert.Equal(ssh, GitCommandRunner.UsesSsh(repository));
[Theory]
[InlineData("ssh", "ssh")]
[InlineData("\"/path with spaces/ssh\"", "/path with spaces/ssh")]
[InlineData("'/path with spaces/ssh'", "/path with spaces/ssh")]
public void GetSshExecutable_AcceptsBareExecutables(string command, string expected) =>
Assert.Equal(expected, GitCommandRunner.GetSshExecutable(command));
[Theory]
[InlineData("")]
[InlineData("ssh -i identity")]
[InlineData("ssh -o BatchMode=no")]
[InlineData("ssh; other-command")]
[InlineData("$SSH_COMMAND")]
[InlineData("ssh | other-command")]
public void GetSshExecutable_RejectsCommandsWithoutInterpretingShellSyntax(string command)
{
var exception = Assert.Throws<GitCommandException>(() => GitCommandRunner.GetSshExecutable(command));
Assert.Equal(GitFailure.UnattendedSshUnsupported, exception.Failure);
Assert.Equal(ErrorStrings.GitUnattendedSshUnsupported, exception.Message);
}
[Theory]
[InlineData("https://example.test/repository", "GIT_SSH_COMMAND", "ssh -i identity", false)]
[InlineData("ssh://git@example.test/repository", "GIT_SSH_COMMAND", "ssh -i identity", true)]
[InlineData("ssh://git@example.test/repository", "GIT_SSH", "custom-wrapper", true)]
[InlineData("ssh://git@example.test/repository", "GIT_SSH", "", true)]
[InlineData("ssh://git@example.test/repository", "core.sshCommand", "ssh -i identity", true)]
[InlineData("ssh://git@example.test/repository", "core.sshCommand", "", true)]
[InlineData("ssh://git@example.test/repository", "GIT_SSH_COMMAND", "plink", true)]
[RequiresTools(["git"])]
public async Task ConfigureUnattendedSsh_RejectsCustomLaunchersOnlyForSsh(string repository, string setting, string launcher, bool rejected)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var config = Path.Combine(workspace.Path, "gitconfig");
var configuration = setting == "core.sshCommand" ? $"[core]\nsshCommand = {launcher}\n" : "";
await File.WriteAllTextAsync(config, configuration);
string[] arguments = ["ls-remote", "--", repository];
var runner = CreateRunner();
var info = runner.CreateStartInfo(workspace.Path, arguments, GitCommandMode.Unattended);
IsolateGit(info, workspace.Path, config);
if (setting != "core.sshCommand")
{
info.Environment[setting] = launcher;
}
if (rejected)
{
var exception = await Assert.ThrowsAsync<GitCommandException>(() =>
runner.ConfigureUnattendedSshAsync(info, arguments, TestContext.Current.CancellationToken));
Assert.Equal(GitFailure.UnattendedSshUnsupported, exception.Failure);
}
else
{
await runner.ConfigureUnattendedSshAsync(info, arguments, TestContext.Current.CancellationToken);
Assert.Equal(launcher, info.Environment[setting]);
}
Assert.Equal(configuration, await File.ReadAllTextAsync(config));
}
[Theory]
[InlineData(false, null)]
[InlineData(true, null)]
[InlineData(false, "GIT_SSH")]
[InlineData(false, "GIT_SSH_COMMAND")]
[InlineData(false, "core.sshCommand")]
[RequiresTools(["git", "ssh"])]
public async Task ConfigureUnattendedSsh_VerifiesOpenSshAndAppliesBatchModeAfterUrlRewrites(bool rewritten, string? setting)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var config = Path.Combine(workspace.Path, "gitconfig");
var configuration = (rewritten ? "[url \"ssh://git@example.test/\"]\ninsteadOf = https://example.test/\n" : "")
+ (setting == "core.sshCommand" ? "[core]\nsshCommand = ssh\n" : "");
await File.WriteAllTextAsync(config, configuration);
string[] arguments = ["ls-remote", "--", rewritten ? "https://example.test/repository" : "ssh://git@example.test/repository"];
var runner = CreateRunner();
var info = runner.CreateStartInfo(workspace.Path, arguments, GitCommandMode.Unattended);
IsolateGit(info, workspace.Path, config);
info.Environment["SSH_AUTH_SOCK"] = "preserved-agent";
if (setting is not null && setting != "core.sshCommand")
{
info.Environment[setting] = "ssh";
}
await runner.ConfigureUnattendedSshAsync(info, arguments, TestContext.Current.CancellationToken);
var ssh = await GetGitSelectedSshAsync(info);
AssertOpenSshCommand(ssh, info.Environment["GIT_SSH_COMMAND"]);
Assert.Equal("ssh", info.Environment["GIT_SSH_VARIANT"]);
Assert.Equal("preserved-agent", info.Environment["SSH_AUTH_SOCK"]);
Assert.Equal(configuration, await File.ReadAllTextAsync(config));
}
[Fact]
[RequiresTools(["git", "ssh"])]
public async Task ConfigureUnattendedSsh_RelativePathEntriesUseGitDirectoryBeforeAbsoluteFallback()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var directory = Directory.CreateDirectory(Path.Combine(workspace.Path,
"git", "acquisition", "snapshot", "nested", "repository", "working", "directory"));
var config = Path.Combine(workspace.Path, "gitconfig");
await File.WriteAllTextAsync(config, "");
var ssh = PathLookupHelper.ResolveExecutablePath("ssh");
var sshDirectory = Path.GetDirectoryName(ssh)!;
var relative = Path.GetRelativePath(Environment.CurrentDirectory, sshDirectory);
string[] arguments = ["ls-remote", "--", "ssh://git@example.test/repository"];
var runner = CreateRunner();
var info = runner.CreateStartInfo(directory.FullName, arguments, GitCommandMode.Unattended);
IsolateGit(info, workspace.Path, config);
info.Environment["PATH"] = $"{relative}{Path.PathSeparator}{sshDirectory}{Path.PathSeparator}{info.Environment["PATH"]}";
await runner.ConfigureUnattendedSshAsync(info, arguments, TestContext.Current.CancellationToken);
var selectedSsh = await GetGitSelectedSshAsync(info);
AssertOpenSshCommand(selectedSsh, info.Environment["GIT_SSH_COMMAND"]);
}
[Fact]
[PlatformSpecific(TestPlatforms.AnyUnix)]
[RequiresTools(["git", "ssh"])]
public async Task ConfigureUnattendedSsh_UsesExecutableDirectoryPrependedByRealGit()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var config = Path.Combine(workspace.Path, "gitconfig");
await File.WriteAllTextAsync(config, "");
var executables = Directory.CreateDirectory(Path.Combine(workspace.Path, "Git child executables"));
var ssh = Path.Combine(executables.FullName, "ssh");
File.CreateSymbolicLink(ssh, PathLookupHelper.ResolveExecutablePath("ssh"));
string[] arguments = ["ls-remote", "--", "ssh://git@example.test/repository"];
var runner = CreateRunner();
var info = runner.CreateStartInfo(workspace.Path, arguments, GitCommandMode.Unattended);
IsolateGit(info, workspace.Path, config);
info.Environment["GIT_EXEC_PATH"] = executables.FullName;
Assert.Equal(ssh, await GetGitSelectedSshAsync(info));
await runner.ConfigureUnattendedSshAsync(info, arguments, TestContext.Current.CancellationToken);
AssertOpenSshCommand(ssh, info.Environment["GIT_SSH_COMMAND"]);
Assert.Equal("", await File.ReadAllTextAsync(config));
}
[Theory]
[InlineData(null)]
[InlineData("GIT_SSH")]
[InlineData("GIT_SSH_COMMAND")]
[InlineData("core.sshCommand")]
[PlatformSpecific(TestPlatforms.Windows)]
[RequiresTools(["git"])]
public async Task ConfigureUnattendedSsh_UsesBundledGitWindowsSshWithoutSshOnParentPath(string? setting)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var config = Path.Combine(workspace.Path, "gitconfig");
var configuration = setting == "core.sshCommand" ? "[core]\nsshCommand = ssh\n" : "";
await File.WriteAllTextAsync(config, configuration);
string[] arguments = ["ls-remote", "--", "ssh://git@example.test/repository"];
var runner = CreateRunner();
var info = runner.CreateStartInfo(workspace.Path, arguments, GitCommandMode.Unattended);
IsolateGit(info, workspace.Path, config);
info.Environment["PATH"] = string.Join(Path.PathSeparator,
info.Environment["PATH"]!.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(directory => !File.Exists(Path.Combine(directory, "ssh.exe")) && !File.Exists(Path.Combine(directory, "ssh"))));
if (setting is not null && setting != "core.sshCommand")
{
info.Environment[setting] = "ssh";
}
Assert.False(PathLookupHelper.TryResolveExecutablePath("ssh", out _, new Dictionary<string, string>
{
["PATH"] = info.Environment["PATH"]!,
["PATHEXT"] = info.Environment["PATHEXT"]!
}));
var selectedSsh = await GetGitSelectedSshAsync(info);
Assert.True(File.Exists(selectedSsh));
Assert.EndsWith("\\usr\\bin\\ssh.exe", selectedSsh, StringComparison.OrdinalIgnoreCase);
await runner.ConfigureUnattendedSshAsync(info, arguments, TestContext.Current.CancellationToken);
AssertOpenSshCommand(selectedSsh, info.Environment["GIT_SSH_COMMAND"]);
Assert.Equal("ssh", info.Environment["GIT_SSH_VARIANT"]);
Assert.Equal(configuration, await File.ReadAllTextAsync(config));
}
[Fact]
public void NormalizeSshPath_ResolvesRelativeEntriesAndSkipsMalformedEntries()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var gitDirectory = Path.Combine(workspace.Path, "git-directory");
var absolute = Path.Combine(workspace.Path, "absolute");
var path = $"tools{Path.PathSeparator}\0invalid{Path.PathSeparator}{absolute}{Path.PathSeparator}{Path.PathSeparator}";
var normalized = GitCommandRunner.NormalizeSshPath(path, gitDirectory);
Assert.Equal($"{Path.Combine(gitDirectory, "tools")}{Path.PathSeparator}{absolute}", normalized);
}
private static string ShellPath(string path) => path.Replace("\\", "/", StringComparison.Ordinal).Replace("'", "'\\''", StringComparison.Ordinal);
private static void AssertOpenSshCommand(string executable, string? command)
{
Assert.NotNull(command);
const string suffix = "' -o BatchMode=yes";
// Windows path casing is immaterial, but -o and -O are different SSH options.
Assert.EndsWith(suffix, command, StringComparison.Ordinal);
Assert.Equal($"'{executable.Replace("'", "'\\''", StringComparison.Ordinal)}", command[..^suffix.Length],
ignoreCase: OperatingSystem.IsWindows());
}
private async Task<string> GetGitSelectedSshAsync(ProcessStartInfo source)
{
// Observe the actual executable Git's shell selects, independently of
// the CLI's PATH lookup. cygpath translates Git for Windows' MSYS path.
var selection = OperatingSystem.IsWindows()
? "!cygpath -aw \"$(command -v ssh.exe)\""
: "!command -v ssh";
var query = CreateRunner().CreateStartInfo(source.WorkingDirectory,
["-c", $"alias.aspire-test-ssh={selection}", "aspire-test-ssh"], GitCommandMode.Unattended);
query.Environment.Clear();
foreach (var (name, value) in source.Environment)
{
query.Environment[name] = value;
}
return (await CreateRunner().RunProcessAsync(query, captureCancellationOutput: false,
GitCommandMode.Unattended, TestContext.Current.CancellationToken)).TrimEnd('\r', '\n');
}
private static void IsolateGit(ProcessStartInfo info, string home, string config)
{
info.Environment["HOME"] = home;
info.Environment["USERPROFILE"] = home;
info.Environment["GIT_CONFIG_GLOBAL"] = config;
info.Environment["GIT_CONFIG_SYSTEM"] = config;
info.Environment["GIT_CONFIG_NOSYSTEM"] = "1";
info.Environment["GIT_CONFIG_COUNT"] = "0";
info.Environment.Remove("GIT_CONFIG_PARAMETERS");
info.Environment.Remove("GIT_ASKPASS");
info.Environment.Remove("SSH_ASKPASS");
info.Environment.Remove("GIT_SSH");
info.Environment.Remove("GIT_SSH_COMMAND");
info.Environment.Remove("GIT_SSH_VARIANT");
info.Environment["NO_PROXY"] = "127.0.0.1";
}
}