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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,6 @@ ASALocalRun/

# MFractors (Xamarin productivity tool) working folder
.mfractor/

# macOS Finder files
.DS_Store
37 changes: 37 additions & 0 deletions Git-Credential-Manager.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A7FC1234-95E3-4496-B5F7-4306F41E6A0E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4B305AC9-153F-4EA3-822F-3E5023BABAF1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "git-credential-manager", "src\git-credential-manager\git-credential-manager.csproj", "{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Git.CredentialManager", "src\Microsoft.Git.CredentialManager\Microsoft.Git.CredentialManager.csproj", "{31BCFC70-B767-4274-873F-1A076D422FC3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Git.CredentialManager.Tests", "tests\Microsoft.Git.CredentialManager.Tests\Microsoft.Git.CredentialManager.Tests.csproj", "{AD41FA1E-51F5-4E4F-B7DA-32F921491313}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
{31BCFC70-B767-4274-873F-1A076D422FC3} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
{AD41FA1E-51F5-4E4F-B7DA-32F921491313} = {4B305AC9-153F-4EA3-822F-3E5023BABAF1}
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Release|Any CPU.Build.0 = Release|Any CPU
{31BCFC70-B767-4274-873F-1A076D422FC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{31BCFC70-B767-4274-873F-1A076D422FC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{31BCFC70-B767-4274-873F-1A076D422FC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{31BCFC70-B767-4274-873F-1A076D422FC3}.Release|Any CPU.Build.0 = Release|Any CPU
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
180 changes: 180 additions & 0 deletions src/Microsoft.Git.CredentialManager/CommandContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace Microsoft.Git.CredentialManager
{
/// <summary>
/// Represents the execution environment for a Git credential helper command.
/// </summary>
public interface ICommandContext
{
/// <summary>
/// The standard input text stream from the calling process, typically Git.
/// </summary>
TextReader StdIn { get; }

/// <summary>
/// The standard output text stream connected back to the calling process, typically Git.
/// </summary>
TextWriter StdOut { get; }

/// <summary>
/// The standard error text stream connected back to the calling process, typically Git.
/// </summary>
TextWriter StdError { get; }

/// <summary>
/// Application tracing system.
/// </summary>
ITrace Trace { get; }

/// <summary>
/// File system abstraction (exists mainly for testing).
/// </summary>
IFileSystem FileSystem { get; }

/// <summary>
/// Access the environment variables for the current GCM process.
/// </summary>
/// <returns>Set of all current environment variables.</returns>
IReadOnlyDictionary<string, string> GetEnvironmentVariables();
}

/// <summary>
/// Real command execution environment using the actual <see cref="Console"/>, file system calls and environment.
/// </summary>
public class CommandContext : ICommandContext
{
private const string LineFeed = "\n";

private static readonly Encoding Utf8NoBomEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

private TextReader _stdIn;
private TextWriter _stdOut;
private TextWriter _stdErr;

#region ICommandContext

public TextReader StdIn
{
get
{
if (_stdIn == null)
{
_stdIn = new StreamReader(Console.OpenStandardInput(), Utf8NoBomEncoding);
}

return _stdIn;
}
}

public TextWriter StdOut
{
get
{
if (_stdOut == null)
{
_stdOut = new StreamWriter(Console.OpenStandardOutput(), Utf8NoBomEncoding)
{
AutoFlush = true,
NewLine = LineFeed,
};
}

return _stdOut;
}
}

public TextWriter StdError
{
get
{
if (_stdErr == null)
{
_stdErr = new StreamWriter(Console.OpenStandardError(), Utf8NoBomEncoding)
{
AutoFlush = true,
NewLine = LineFeed,
};
}

return _stdErr;
}
}

public ITrace Trace { get; } = new Trace();

public IFileSystem FileSystem { get; } = new FileSystem();

public IReadOnlyDictionary<string, string> GetEnvironmentVariables()
{
IDictionary variables = Environment.GetEnvironmentVariables();

// On Windows it is technically possible to get env vars which differ only by case
// even though the general assumption is that they are case insensitive on Windows.
// For example, some of the standard .NET types like System.Diagnostics.Process
// will fail to start a process on Windows if given duplicate environment variables.
// See this issue for more information: https://github.com/dotnet/corefx/issues/13146

// If we're on the Windows platform we should de-duplicate by setting the string
// comparer to OrdinalIgnoreCase.
var comparer = PlatformUtils.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;

var result = new Dictionary<string, string>(comparer);

foreach (var key in variables.Keys)
{
if (key is string name && variables[key] is string value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you actually expect to get key / values that are not strings? or are you being defensive here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I do not, and yes this is partly for defensive purposes 😀

Also since the type you get back from Environment.GetEnvironmentVariables() is the non-generic IDictionary and I want to offer the friendlier and more useful (IMO) generic IDictionary<string, string>, this syntax offers a nice way to both safely cast the object/object key-value pair to strings that are also non-null (a null value should be 'not set'/'not present').

{
result[name] = value;
}
}

return result;
}

#endregion
}

public static class CommandContextExtensions
{
/// <summary>
/// Try to get the current value of the specified environment variable.
/// </summary>
/// <param name="context"><see cref="ICommandContext"/></param>
/// <param name="key">The name of environment variable.</param>
/// <param name="value">The current value of the environment variable.</param>
/// <returns>True if the environment variable was set and has a value, false otherwise.</returns>
public static bool TryGetEnvironmentVariable(this ICommandContext context, string key, out string value)
{
return context.GetEnvironmentVariables().TryGetValue(key, out value);
}

/// <summary>
/// Test if the specified environment variable is 'truthy' (considered to be equivalent to a 'true' value
/// by <see cref="StringExtensions.IsTruthy"/>).
/// </summary>
/// <param name="context"><see cref="ICommandContext"/></param>
/// <param name="key">The name of environment variable.</param>
/// <param name="defaultValue">
/// The assumed default value of the environment variable if it does not
/// exist/has not been set.
/// </param>
/// <returns>True if the environment variable was set and has a 'truthy' value, <see cref="defaultValue"/> otherwise.</returns>
public static bool IsEnvironmentVariableTruthy(this ICommandContext context, string key, bool defaultValue)
{
if (context.TryGetEnvironmentVariable(key, out string valueStr) && valueStr.IsTruthy())
{
return true;
}

return defaultValue;
}
}
}
88 changes: 88 additions & 0 deletions src/Microsoft.Git.CredentialManager/Commands/Command.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Microsoft.Git.CredentialManager.Commands
{
/// <summary>
/// Represents a Git Credential Manager command.
/// </summary>
public abstract class CommandBase
{
/// <summary>
/// Check if this command should be executed for the given arguments.
/// </summary>
/// <param name="args">Application command-line arguments.</param>
/// <returns>True if the command should be executed, false otherwise.</returns>
public abstract bool CanExecute(string[] args);

/// <summary>
/// Execute the command.
/// </summary>
/// <param name="context">The current command execution context.</param>
/// <param name="args">Application command-line arguments.</param>
/// <returns>Awaitable task for the command execution.</returns>
public abstract Task ExecuteAsync(ICommandContext context, string[] args);
}

/// <summary>
/// Represents a simple Git Credential Manager command that takes a single named verb.
/// </summary>
public abstract class VerbCommandBase : CommandBase
{
/// <summary>
/// Name of the command verb.
/// </summary>
protected abstract string Name { get; }

public override bool CanExecute(string[] args)
{
return args.Length != 0 && StringComparer.OrdinalIgnoreCase.Equals(args[0], Name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would you expect to have args[0] not equal to Name?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we're invoked with any command which doesn't match git-credential-manager <verb>?

The CanExecute method of every command could be called for any and all input (except args.Length == 0; that is just defensive), testing if the command feels it should execute given the command-line arguments.

The VerbCommandBase commands all follow the same pattern of gcm <verb> with no extra switches/flags, and are selected by their name as the first argument.

}
}

/// <summary>
/// Represents a command which selects a <see cref="IHostProvider"/> from a <see cref="IHostProviderRegistry"/>
/// based on the <see cref="InputArguments"/> from standard input, and interacts with a <see cref="GitCredential"/>.
/// </summary>
public abstract class HostProviderCommandBase : VerbCommandBase
{
private readonly IHostProviderRegistry _hostProviderRegistry;

protected HostProviderCommandBase(IHostProviderRegistry hostProviderRegistry)
{
_hostProviderRegistry = hostProviderRegistry;
}

public override async Task ExecuteAsync(ICommandContext context, string[] args)
{
// Parse standard input arguments
// git-credential treats the keys as case-sensitive; so should we.
IDictionary<string, string> inputDict = await context.StdIn.ReadDictionaryAsync(StringComparer.Ordinal);
var input = new InputArguments(inputDict);

// Determine the host provider
context.Trace.WriteLine("Detecting host provider for input:");
context.Trace.WriteDictionary(inputDict);
IHostProvider provider = _hostProviderRegistry.GetProvider(input);
context.Trace.WriteLine($"Host provider '{provider.Name}' was selected.");

// Build the credential identifier
string hostProviderKey = provider.GetCredentialKey(input);
string credentialKey = $"git:{hostProviderKey}";
context.Trace.WriteLine($"Credential key is '{credentialKey}'.");

await ExecuteInternalAsync(context, input, provider, credentialKey);
}

/// <summary>
/// Execute the command using the given <see cref="InputArguments"/>, <see cref="IHostProvider"/>, and <see cref="GitCredential"/> key.
/// </summary>
/// <param name="context">The current command execution context.</param>
/// <param name="input">Input arguments of the current Git credential query.</param>
/// <param name="provider">Host provider for the current <see cref="InputArguments"/>.</param>
/// <param name="credentialKey">Unique identifier in the OS secure storage system for a <see cref="GitCredential"/>.</param>
/// <returns>Awaitable task for the command execution.</returns>
protected abstract Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey);
}
}
21 changes: 21 additions & 0 deletions src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Threading.Tasks;

namespace Microsoft.Git.CredentialManager.Commands
{
/// <summary>
/// Erase a previously stored <see cref="GitCredential"/> from the OS secure credential store.
/// </summary>
public class EraseCommand : HostProviderCommandBase
{
public EraseCommand(IHostProviderRegistry hostProviderRegistry)
: base(hostProviderRegistry) { }

protected override string Name => "erase";

protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey)
{
throw new NotImplementedException();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not implemented because this is a base class? or because it hasn't been implemented yet?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not implemented because it's not implemented (in this PR) yet. I'm intentionally holding back some of the code of the core credential helper commands and host providers to keep this PR more manageable; they're coming in subsequent PRs.

I expect this initial PR to be the largest.

}
}
}
21 changes: 21 additions & 0 deletions src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Threading.Tasks;

namespace Microsoft.Git.CredentialManager.Commands
{
/// <summary>
/// Acquire a new <see cref="GitCredential"/> from a <see cref="IHostProvider"/>.
/// </summary>
public class GetCommand : HostProviderCommandBase
{
public GetCommand(IHostProviderRegistry hostProviderRegistry)
: base(hostProviderRegistry) { }

protected override string Name => "get";

protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey)
{
throw new NotImplementedException();
}
}
}
Loading