diff --git a/.gitignore b/.gitignore index 3e759b75b..8361a679b 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,6 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ + +# macOS Finder files +.DS_Store diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln new file mode 100644 index 000000000..f31dc7c8c --- /dev/null +++ b/Git-Credential-Manager.sln @@ -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 diff --git a/src/Microsoft.Git.CredentialManager/CommandContext.cs b/src/Microsoft.Git.CredentialManager/CommandContext.cs new file mode 100644 index 000000000..eac1d5152 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/CommandContext.cs @@ -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 +{ + /// + /// Represents the execution environment for a Git credential helper command. + /// + public interface ICommandContext + { + /// + /// The standard input text stream from the calling process, typically Git. + /// + TextReader StdIn { get; } + + /// + /// The standard output text stream connected back to the calling process, typically Git. + /// + TextWriter StdOut { get; } + + /// + /// The standard error text stream connected back to the calling process, typically Git. + /// + TextWriter StdError { get; } + + /// + /// Application tracing system. + /// + ITrace Trace { get; } + + /// + /// File system abstraction (exists mainly for testing). + /// + IFileSystem FileSystem { get; } + + /// + /// Access the environment variables for the current GCM process. + /// + /// Set of all current environment variables. + IReadOnlyDictionary GetEnvironmentVariables(); + } + + /// + /// Real command execution environment using the actual , file system calls and environment. + /// + 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 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(comparer); + + foreach (var key in variables.Keys) + { + if (key is string name && variables[key] is string value) + { + result[name] = value; + } + } + + return result; + } + + #endregion + } + + public static class CommandContextExtensions + { + /// + /// Try to get the current value of the specified environment variable. + /// + /// + /// The name of environment variable. + /// The current value of the environment variable. + /// True if the environment variable was set and has a value, false otherwise. + public static bool TryGetEnvironmentVariable(this ICommandContext context, string key, out string value) + { + return context.GetEnvironmentVariables().TryGetValue(key, out value); + } + + /// + /// Test if the specified environment variable is 'truthy' (considered to be equivalent to a 'true' value + /// by ). + /// + /// + /// The name of environment variable. + /// + /// The assumed default value of the environment variable if it does not + /// exist/has not been set. + /// + /// True if the environment variable was set and has a 'truthy' value, otherwise. + public static bool IsEnvironmentVariableTruthy(this ICommandContext context, string key, bool defaultValue) + { + if (context.TryGetEnvironmentVariable(key, out string valueStr) && valueStr.IsTruthy()) + { + return true; + } + + return defaultValue; + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Commands/Command.cs b/src/Microsoft.Git.CredentialManager/Commands/Command.cs new file mode 100644 index 000000000..d7f580437 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Commands/Command.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager.Commands +{ + /// + /// Represents a Git Credential Manager command. + /// + public abstract class CommandBase + { + /// + /// Check if this command should be executed for the given arguments. + /// + /// Application command-line arguments. + /// True if the command should be executed, false otherwise. + public abstract bool CanExecute(string[] args); + + /// + /// Execute the command. + /// + /// The current command execution context. + /// Application command-line arguments. + /// Awaitable task for the command execution. + public abstract Task ExecuteAsync(ICommandContext context, string[] args); + } + + /// + /// Represents a simple Git Credential Manager command that takes a single named verb. + /// + public abstract class VerbCommandBase : CommandBase + { + /// + /// Name of the command verb. + /// + protected abstract string Name { get; } + + public override bool CanExecute(string[] args) + { + return args.Length != 0 && StringComparer.OrdinalIgnoreCase.Equals(args[0], Name); + } + } + + /// + /// Represents a command which selects a from a + /// based on the from standard input, and interacts with a . + /// + 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 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); + } + + /// + /// Execute the command using the given , , and key. + /// + /// The current command execution context. + /// Input arguments of the current Git credential query. + /// Host provider for the current . + /// Unique identifier in the OS secure storage system for a . + /// Awaitable task for the command execution. + protected abstract Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey); + } +} diff --git a/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs new file mode 100644 index 000000000..d06d31b2b --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager.Commands +{ + /// + /// Erase a previously stored from the OS secure credential store. + /// + 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(); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs new file mode 100644 index 000000000..cba14d5d8 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager.Commands +{ + /// + /// Acquire a new from a . + /// + 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(); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Commands/HelpCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/HelpCommand.cs new file mode 100644 index 000000000..56f3756c1 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Commands/HelpCommand.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager.Commands +{ + /// + /// Print usage information and basic help for Git Credential Manager. + /// + public class HelpCommand : CommandBase + { + private readonly string _appName; + + public HelpCommand(string appName) + { + _appName = appName ?? throw new ArgumentNullException(nameof(appName)); + } + + public override bool CanExecute(string[] args) + { + return args.Any(x => StringComparer.OrdinalIgnoreCase.Equals(x, "--help") || + StringComparer.OrdinalIgnoreCase.Equals(x, "-h") || + StringComparer.OrdinalIgnoreCase.Equals(x, "help") || + (x != null && x.Contains('?'))); + } + + public override Task ExecuteAsync(ICommandContext context, string[] args) + { + context.StdOut.WriteLine(Constants.GcmProgramNameFormat, Constants.GcmVersion, PlatformUtils.GetOSInfo()); + + PrintUsage(context.StdOut); + + return Task.CompletedTask; + } + + /// + /// Print the standard usage documentation for Git Credential Manager to the given . + /// + /// Text writer to write usage information to. + public void PrintUsage(TextWriter writer) + { + writer.WriteLine(); + writer.WriteLine("usage: {0} ", _appName); + writer.WriteLine(); + writer.WriteLine(" Available commands:"); + writer.WriteLine(" erase"); + writer.WriteLine(" get"); + writer.WriteLine(" store"); + writer.WriteLine(); + writer.WriteLine(" --version, version"); + writer.WriteLine(" --help, -h, -?"); + writer.WriteLine(); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Commands/StoreCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/StoreCommand.cs new file mode 100644 index 000000000..c4785dfeb --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Commands/StoreCommand.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager.Commands +{ + /// + /// Store a previously created in the OS secure credential store. + /// + public class StoreCommand : HostProviderCommandBase + { + public StoreCommand(IHostProviderRegistry hostProviderRegistry) + : base(hostProviderRegistry) { } + + protected override string Name => "store"; + + protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Commands/VersionCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/VersionCommand.cs new file mode 100644 index 000000000..9c93e1764 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Commands/VersionCommand.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager.Commands +{ + /// + /// Print version information for Git Credential Manager. + /// + public class VersionCommand : CommandBase + { + private readonly string _header; + + public VersionCommand(string header) + { + _header = header; + } + + public override bool CanExecute(string[] args) + { + return args.Any(x => StringComparer.OrdinalIgnoreCase.Equals(x, "--version")) + || args.Any(x => StringComparer.OrdinalIgnoreCase.Equals(x, "version")); + } + + public override Task ExecuteAsync(ICommandContext context, string[] args) + { + context.StdOut.WriteLine(_header); + + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Constants.cs b/src/Microsoft.Git.CredentialManager/Constants.cs new file mode 100644 index 000000000..4c158d519 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Constants.cs @@ -0,0 +1,28 @@ +namespace Microsoft.Git.CredentialManager +{ + public static class Constants + { + public const string GcmVersion = "1.0"; + public const string PersonalAccessTokenUserName = "PersonalAccessToken"; + + public static class EnvironmentVariables + { + public const string GcmTrace = "GCM_TRACE"; + public const string GcmTraceSecrets = "GCM_TRACE_SECRETS"; + public const string GcmDebug = "GCM_DEBUG"; + } + + public static readonly string GcmProgramNameFormat = "Git Credential Manager Core (version {0}, {1})"; + + /// + /// Get standard program header title for Git Credential Manager, including the current version and OS information. + /// + /// Standard program header. + public static string GetProgramHeader() + { + string os = PlatformUtils.GetOSInfo(); + + return string.Format(GcmProgramNameFormat, GcmVersion, os); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/EnsureArgument.cs b/src/Microsoft.Git.CredentialManager/EnsureArgument.cs new file mode 100644 index 000000000..fa2ef3e89 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/EnsureArgument.cs @@ -0,0 +1,77 @@ +using System; + +namespace Microsoft.Git.CredentialManager +{ + public static class EnsureArgument + { + public static void NotNull(T arg, string name) where T : class + { + if (arg is null) + { + throw new ArgumentNullException(name); + } + } + + public static void NotNullOrEmpty(string arg, string name) + { + NotNull(arg, name); + + if (string.IsNullOrEmpty(arg)) + { + throw new ArgumentException("Argument cannot be empty.", name); + } + } + + public static void NotNullOrWhiteSpace(string arg, string name) + { + NotNull(arg, name); + + if (string.IsNullOrWhiteSpace(arg)) + { + throw new ArgumentException("Argument cannot be empty or white space.", name); + } + } + + public static void AbsoluteUri(Uri arg, string name) + { + NotNull(arg, name); + + if (!arg.IsAbsoluteUri) + { + throw new ArgumentException("Argument must be an absolute URI.", name); + } + } + + public static void PositiveOrZero(int arg, string name) + { + if (arg < 0) + { + throw new ArgumentOutOfRangeException(name, "Argument must be positive or zero (non-negative)."); + } + } + + public static void Positive(int arg, string name) + { + if (arg <= 0) + { + throw new ArgumentOutOfRangeException(name, "Argument must be positive."); + } + } + + public static void NegativeOrZero(int arg, string name) + { + if (arg > 0) + { + throw new ArgumentOutOfRangeException(name, "Argument must be negative or zero (non-positive)."); + } + } + + public static void Negative(int arg, string name) + { + if (arg >= 0) + { + throw new ArgumentOutOfRangeException(name, "Argument must be negative."); + } + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/FileSystem.cs b/src/Microsoft.Git.CredentialManager/FileSystem.cs new file mode 100644 index 000000000..1d9a5a959 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/FileSystem.cs @@ -0,0 +1,55 @@ +using System.IO; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Represents a file system and operations that can be performed. + /// + public interface IFileSystem + { + /// + /// Check if a file exists at the specified path. + /// + /// Full path to file to test. + /// True if a file exists, false otherwise. + bool FileExists(string path); + + /// + /// Check if a directory exists at the specified path. + /// + /// Full path to directory to test. + /// True if a directory exists, false otherwise. + bool DirectoryExists(string path); + + /// + /// Get the path to the current directory of the currently executing process. + /// + /// Current process directory. + string GetCurrentDirectory(); + + /// + /// Open a file stream at the specified path with the given access and mode settings. + /// + /// Full file path. + /// File mode settings. + /// File access settings. + /// File share settings. + /// + Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare); + } + + /// + /// The real file system. + /// + public class FileSystem : IFileSystem + { + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); + + public Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare) + => File.Open(path, fileMode, fileAccess, fileShare); + } +} diff --git a/src/Microsoft.Git.CredentialManager/GitCredential.cs b/src/Microsoft.Git.CredentialManager/GitCredential.cs new file mode 100644 index 000000000..821258cad --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/GitCredential.cs @@ -0,0 +1,38 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Represents a credential (username/password pair) that Git can use to authenticate to a remote repository. + /// + public class GitCredential + { + public GitCredential(string userName, string password) + { + UserName = userName; + Password = password; + } + + /// + /// User name. + /// + public string UserName { get; } + + /// + /// Password. + /// + public string Password { get; } + + /// + /// Returns the base-64 encoded, {username}:{password} formatted string of this ``. + /// + public string ToBase64String() + { + string basicAuthValue = string.Format(CultureInfo.InvariantCulture, "{0}:{1}", UserName, Password); + byte[] authBytes = Encoding.UTF8.GetBytes(basicAuthValue); + return Convert.ToBase64String(authBytes); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/HostProvider.cs b/src/Microsoft.Git.CredentialManager/HostProvider.cs new file mode 100644 index 000000000..8cbad8dca --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/HostProvider.cs @@ -0,0 +1,81 @@ +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Represents a particular Git hosting service and provides for the creation of credentials to access the remote. + /// + public interface IHostProvider + { + /// + /// Name of the hosting provider. + /// + string Name { get; } + + /// + /// Determine if the are recognized by this particular Git hosting provider. + /// + /// Input arguments of a Git credential query. + /// True if the provider supports the Git credential request, false otherwise. + bool IsSupported(InputArguments input); + + /// + /// Return a key that uniquely represents the given Git credential query arguments. + /// + /// + /// This key forms part of the identifier used to retrieve and store credentials from the OS secure + /// credential storage system. It is important the returned value is stable over time to avoid any + /// potential re-authentication requests. + /// + /// Input arguments of a Git credential query. + /// + string GetCredentialKey(InputArguments input); + + /// + /// Create a new credential for accessing the remote Git repository on this hosting service. + /// + /// Input arguments of a Git credential query. + /// A new credential Git can use to authenticate to the remote repository. + Task CreateCredentialAsync(InputArguments input); + + /// + /// Whether or not s created by this provider should be stored in the + /// secure credential storage system when the call to is made. + /// + /// + /// Some host providers may wish to or need to store s created by them + /// immediately after they are created, rather than at a later time when requested to do so by Git. + /// One example reason for this is that the provider is unable to create the same credential key + /// due to missing information in the on subsequent calls to + /// . + /// + /// If this property returns true, Git Credential Manager will store any credential created by this + /// provider during the `git credential-helper get` call, rather than in a `store` call (which will + /// now be a no-op). + /// + bool IsCredentialStoredOnCreation { get; } + } + + public abstract class HostProvider : IHostProvider + { + protected HostProvider(ICommandContext context) + { + Context = context; + } + + /// + /// The current command execution context. + /// + protected ICommandContext Context { get; } + + public abstract string Name { get; } + + public abstract bool IsSupported(InputArguments input); + + public abstract string GetCredentialKey(InputArguments input); + + public abstract Task CreateCredentialAsync(InputArguments input); + + public abstract bool IsCredentialStoredOnCreation { get; } + } +} diff --git a/src/Microsoft.Git.CredentialManager/HostProviderRegistry.cs b/src/Microsoft.Git.CredentialManager/HostProviderRegistry.cs new file mode 100644 index 000000000..4987655c3 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/HostProviderRegistry.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Represents a collection of s which are selected based on Git credential query + /// . + /// + public interface IHostProviderRegistry + { + /// + /// Add the given (s) to this registry. + /// + /// A collection of providers to register. + void Register(params IHostProvider[] hostProviders); + + /// + /// Select a that can service the Git credential query based on the + /// . + /// + /// Input arguments of a Git credential query. + /// A host provider that can service the given query. + IHostProvider GetProvider(InputArguments input); + } + + /// + /// A simple host provider registry where each provider is queried in registration order until the first + /// provider that supports the credential query is found. + /// + public class HostProviderRegistry : IHostProviderRegistry + { + private readonly List _hostProviders = new List(); + + public void Register(params IHostProvider[] hostProviders) + { + if (hostProviders == null) + { + throw new ArgumentNullException(nameof(hostProviders)); + } + + _hostProviders.AddRange(hostProviders); + } + + public IHostProvider GetProvider(InputArguments input) + { + var provider = _hostProviders.FirstOrDefault(x => x.IsSupported(input)); + + if (provider == null) + { + throw new Exception("No host provider available to service this request."); + } + + return provider; + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/InputArguments.cs b/src/Microsoft.Git.CredentialManager/InputArguments.cs new file mode 100644 index 000000000..ff354230f --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/InputArguments.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Represents the input for a Git credential query such as get, erase, or store. + /// + /// + /// This class surfaces the input that is streamed over standard in from Git which provides + /// the credential helper the remote repository information, including the protocol, host, + /// and remote repository path. + /// + public class InputArguments + { + private readonly IReadOnlyDictionary _dict; + + public InputArguments(IDictionary dict) + { + if (dict == null) + { + throw new ArgumentNullException(nameof(dict)); + } + + // Wrap the dictionary internally as readonly + _dict = new ReadOnlyDictionary(dict); + } + + #region Common Arguments + + public string Protocol => GetArgumentOrDefault("protocol"); + public string Host => GetArgumentOrDefault("host"); + public string Path => GetArgumentOrDefault("path"); + public string UserName => GetArgumentOrDefault("username"); + public string Password => GetArgumentOrDefault("password"); + + #endregion + + #region Public Methods + + public string this[string key] + { + get => GetArgumentOrDefault(key); + } + + public string GetArgumentOrDefault(string key) + { + return _dict.TryGetValue(key, out string value) ? value : null; + } + + #endregion + } +} diff --git a/src/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj b/src/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj new file mode 100644 index 000000000..52d4ca7ca --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj @@ -0,0 +1,10 @@ + + + + netstandard2.0 + latest + Microsoft.Git.CredentialManager + Microsoft.Git.CredentialManager + + + diff --git a/src/Microsoft.Git.CredentialManager/PlatformUtils.cs b/src/Microsoft.Git.CredentialManager/PlatformUtils.cs new file mode 100644 index 000000000..295f2dff3 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/PlatformUtils.cs @@ -0,0 +1,118 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager +{ + public static class PlatformUtils + { + /// + /// Get information about the Operating System. + /// + /// + public static string GetOSInfo() + { + if (IsWindows()) + { + return "Windows"; + } + + if (IsMacOS()) + { + return "macOS"; + } + + if (IsLinux()) + { + return "Linux"; + } + + return "Unknown"; + } + + /// + /// Check if the current Operating System is macOS. + /// + /// True if running on macOS, false otherwise. + public static bool IsMacOS() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + + /// + /// Check if the current Operating System is Windows. + /// + /// True if running on Windows, false otherwise. + public static bool IsWindows() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + } + + /// + /// Check if the current Operating System is Linux-based. + /// + /// True if running on a Linux distribution, false otherwise. + public static bool IsLinux() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + } + + /// + /// Ensure the current Operating System is macOS, fail otherwise. + /// + /// Thrown if the current OS is not macOS. + public static void EnsureMacOS() + { + if (!IsMacOS()) + { + throw new PlatformNotSupportedException(); + } + } + + /// + /// Ensure the current Operating System is Windows, fail otherwise. + /// + /// Thrown if the current OS is not Windows. + public static void EnsureWindows() + { + if (!IsWindows()) + { + throw new PlatformNotSupportedException(); + } + } + + /// + /// Ensure the current Operating System is Linux-based, fail otherwise. + /// + /// Thrown if the current OS is not Linux-based. + public static void EnsureLinux() + { + if (!IsLinux()) + { + throw new PlatformNotSupportedException(); + } + } + + /// + /// Wait until a debugger has attached to the currently executing process. + /// + public static void WaitForDebuggerAttached() + { + // Attempt to launch the debugger if the OS supports the explicit launching + if (!Debugger.Launch()) + { + // The prompt to debug was declined + return; + } + + // Wait for the debugger to attach and poll & sleep until then + while (!Debugger.IsAttached) + { + Thread.Sleep(TimeSpan.FromSeconds(1)); + } + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/StreamExtensions.cs b/src/Microsoft.Git.CredentialManager/StreamExtensions.cs new file mode 100644 index 000000000..ffbcfc205 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/StreamExtensions.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Git.CredentialManager +{ + public static class StreamExtensions + { + /// + /// Read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified . + /// + /// Uses the comparer for dictionary keys. + /// Text reader to read a dictionary from. + /// Dictionary read from the text reader. + public static IDictionary ReadDictionary(this TextReader reader) => + ReadDictionary(reader, StringComparer.Ordinal); + + /// + /// Read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified , + /// with the specified used to compare dictionary keys. + /// + /// Text reader to read a dictionary from. + /// Comparer to use when comparing dictionary keys. + /// Dictionary read from the text reader. + public static IDictionary ReadDictionary(this TextReader reader, StringComparer comparer) + { + var dict = new Dictionary(comparer); + + string line; + while ((line = reader.ReadLine()) != null && !string.IsNullOrWhiteSpace(line)) + { + ParseLine(dict, line); + } + + return dict; + } + + /// + /// Asynchronously read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified . + /// + /// Uses the comparer for dictionary keys. + /// Text reader to read a dictionary from. + /// Dictionary read from the text reader. + public static Task> ReadDictionaryAsync(this TextReader reader) => + ReadDictionaryAsync(reader, StringComparer.Ordinal); + + /// + /// Asynchronously read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified , + /// with the specified used to compare dictionary keys. + /// + /// Text reader to read a dictionary from. + /// Comparer to use when comparing dictionary keys. + /// Dictionary read from the text reader. + public static async Task> ReadDictionaryAsync(this TextReader reader, StringComparer comparer) + { + var dict = new Dictionary(comparer); + + string line; + while ((line = await reader.ReadLineAsync()) != null && !string.IsNullOrWhiteSpace(line)) + { + ParseLine(dict, line); + } + + return dict; + } + + /// + /// Write a dictionary in the form `key1=value\nkey2=value\n\n` to the specified . + /// + /// Text writer to write a dictionary to. + /// Dictionary to write to the text writer. + public static void WriteDictionary(this TextWriter writer, IDictionary dict) + { + foreach (var kvp in dict) + { + WriteKeyValuePair(writer, kvp); + } + + // Write terminating line + writer.WriteLine(); + } + + /// + /// Asynchronously write a dictionary in the form `key1=value\nkey2=value\n\n` to the specified . + /// + /// Text writer to write a dictionary to. + /// Dictionary to write to the text writer. + public static async Task WriteDictionaryAsync(this TextWriter writer, IDictionary dict) + { + foreach (var kvp in dict) + { + await WriteKeyValuePairAsync(writer, kvp); + } + + // Write terminating line + await writer.WriteLineAsync(); + } + + private static void WriteKeyValuePair(this TextWriter writer, KeyValuePair kvp) + => WriteKeyValuePair(writer, kvp.Key, kvp.Value); + + private static void WriteKeyValuePair(this TextWriter writer, string key, string value) + { + writer.WriteLine("{0}={1}", key, value); + } + + private static Task WriteKeyValuePairAsync(this TextWriter writer, KeyValuePair kvp) + => WriteKeyValuePairAsync(writer, kvp.Key, kvp.Value); + + private static Task WriteKeyValuePairAsync(this TextWriter writer, string key, string value) + { + return writer.WriteLineAsync($"{key}={value}"); + } + + private static void ParseLine(IDictionary dict, string line) + { + int splitIndex = line.IndexOf('='); + if (splitIndex > 0) + { + string key = line.Substring(0, splitIndex); + string value = line.Substring(splitIndex + 1); + + dict[key] = value; + } + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/StringExtensions.cs b/src/Microsoft.Git.CredentialManager/StringExtensions.cs new file mode 100644 index 000000000..86d09ca60 --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/StringExtensions.cs @@ -0,0 +1,27 @@ +using System; + +namespace Microsoft.Git.CredentialManager +{ + public static class StringExtensions + { + /// + /// Check if the string is considered to be 'truthy' (a value considered equivalent to 'true'). + /// + /// + /// Git considers several different values to be equivalent to 'true'; we try to be consistent with this + /// behavior. + /// + /// See the following Git documentation for a list of values considered to be equivalent to 'true': + /// https://git-scm.com/docs/git-config#git-config-boolean + /// + /// String value to check. + /// True if the value is 'truthy', false otherwise. + public static bool IsTruthy(this string str) + { + return StringComparer.OrdinalIgnoreCase.Equals(str, bool.TrueString) || + StringComparer.OrdinalIgnoreCase.Equals(str, "1") || + StringComparer.OrdinalIgnoreCase.Equals(str, "on") || + StringComparer.OrdinalIgnoreCase.Equals(str, "yes"); + } + } +} diff --git a/src/Microsoft.Git.CredentialManager/Trace.cs b/src/Microsoft.Git.CredentialManager/Trace.cs new file mode 100644 index 000000000..d4e278a0b --- /dev/null +++ b/src/Microsoft.Git.CredentialManager/Trace.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Represents the application's tracing system. + /// + public interface ITrace + { + /// + /// Get or set whether or not sensitive information such as secrets and credentials should be + /// output to attached trace listeners. + /// + bool EnableSecretTracing { get; set; } + + /// + /// Add a listener to the trace writer. + /// + /// The listener to add. + void AddListener(TextWriter listener); + + /// + /// Forces any pending trace messages to be written to any listeners. + /// + void Flush(); + + /// + /// Writes an exception as a message to the trace writer. + /// + /// Expands exceptions' inner exceptions into additional trace lines. + /// + /// The exception to write. + /// Path of the file this method is called from. + /// Line number of file this method is called from. + /// Name of the member in which this method is called. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + void WriteException( + Exception exception, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = ""); + + /// + /// Write the contents of a dictionary to the trace writer. + /// + /// Calls on all keys and values. + /// + /// The dictionary to write. + /// Path of the file this method is called from. + /// Line number of file this method is called from. + /// Name of the member in which this method is called. + void WriteDictionary( + IDictionary dictionary, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = ""); + + /// + /// Writes a message to the trace writer followed by a line terminator. + /// + /// The message to write. + /// Path of the file this method is called from. + /// Line number of file this method is called from. + /// Name of the member in which this method is called. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + void WriteLine( + string message, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = ""); + + /// + /// Writes a message containing sensitive information to the trace writer followed by a line terminator. + /// + /// Attached listeners will only receive the fully formatted message if is set + /// to true, otherwise the secret arguments will be masked. + /// + /// The format string to write. + /// Sensitive/secret arguments for the format string. + /// Path of the file this method is called from. + /// Line number of file this method is called from. + /// Name of the member in which this method is called. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + void WriteLineSecrets( + string format, + object[] secrets, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = ""); + } + + internal class Trace : ITrace, IDisposable + { + private readonly object _writersLock = new object(); + private readonly List _writers = new List(); + + public bool EnableSecretTracing { get; set; } + + public void AddListener(TextWriter listener) + { + lock (_writersLock) + { + // Try not to add the same listener more than once + if (_writers.Contains(listener)) + return; + + _writers.Add(listener); + } + } + + ~Trace() + { + Dispose(true); + } + + public void Dispose() + { + Dispose(false); + GC.SuppressFinalize(this); + } + + public void Flush() + { + lock (_writersLock) + { + foreach (var writer in _writers) + { + try + { + writer?.Flush(); + } + catch + { /* squelch */ } + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + public void WriteException( + Exception exception, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = "") + { + // Exception being null probably won't happen, but we shouldn't die because we failed to trace it. + if (exception is null) + return; + + WriteLine($"! error: '{exception.Message}'.", filePath, lineNumber, memberName); + + while ((exception = exception.InnerException) != null) + { + WriteLine($" > '{exception.Message}'.", filePath, lineNumber, memberName); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + public void WriteDictionary( + IDictionary dictionary, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = "") + { + foreach (KeyValuePair entry in dictionary) + { + WriteLine($"\t{entry.Key}={entry.Value}", filePath, lineNumber, memberName); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + public void WriteLine( + string message, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = "") + { + lock (_writersLock) + { + if (_writers.Count == 0) + { + return; + } + + string text = FormatText(message, filePath, lineNumber, memberName); + + foreach (var writer in _writers) + { + try + { + writer?.Write(text); + writer?.Write('\n'); + writer?.Flush(); + } + catch { /* squelch */ } + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")] + public void WriteLineSecrets( + string format, + object[] secrets, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0, + [System.Runtime.CompilerServices.CallerMemberName] string memberName = "") + { + string message = this.EnableSecretTracing + ? string.Format(format, secrets) + : string.Format(format, secrets.Select(x => "********")); + + WriteLine(message, filePath, lineNumber, memberName); + } + + private void Dispose(bool finalizing) + { + if (!finalizing) + { + lock (_writersLock) + { + try + { + for (int i = 0; i < _writers.Count; i += 1) + { + using (var writer = _writers[i]) + { + _writers.Remove(writer); + } + } + } + catch + { /* squelch */ } + } + } + } + + private static string FormatText(string message, string filePath, int lineNumber, string memberName) + { + const int sourceColumnMaxWidth = 23; + + EnsureArgument.NotNull(message, nameof(message)); + EnsureArgument.NotNull(filePath, nameof(filePath)); + EnsureArgument.PositiveOrZero(lineNumber, nameof(lineNumber)); + EnsureArgument.NotNull(memberName, nameof(memberName)); + + // Source column format is file:line + string source = $"{filePath}:{lineNumber}"; + + if (source.Length > sourceColumnMaxWidth) + { + int idx = 0; + int maxlen = sourceColumnMaxWidth - 3; + int srclen = source.Length; + + while (idx >= 0 && (srclen - idx) > maxlen) + { + idx = source.IndexOf('\\', idx + 1); + } + + // If we cannot find a path separator which allows the path to be long enough, just truncate the file name + if (idx < 0) + { + idx = srclen - maxlen; + } + + source = "..." + source.Substring(idx); + } + + // Git's trace format is "{timestamp,-15} {source,-23} trace: {details}" + string text = $"{DateTime.Now:HH:mm:ss.ffffff} {source,-23} trace: [{memberName}] {message}"; + + return text; + } + } +} diff --git a/src/git-credential-manager/Application.cs b/src/git-credential-manager/Application.cs new file mode 100644 index 000000000..f47aa73a2 --- /dev/null +++ b/src/git-credential-manager/Application.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Git.CredentialManager.Commands; + +namespace Microsoft.Git.CredentialManager +{ + public static class Application + { + private static readonly string ExecutableName = + Path.GetFileName(typeof(Application).Assembly.CodeBase); + + private static readonly string ApplicationHeader = + Constants.GetProgramHeader(); + + private static readonly IHostProviderRegistry HostProviderRegistry = new HostProviderRegistry(); + + private static readonly HelpCommand HelpCommand = new HelpCommand(ExecutableName); + + private static readonly ICollection Commands = new CommandBase[] + { + new EraseCommand(HostProviderRegistry), + new GetCommand(HostProviderRegistry), + new StoreCommand(HostProviderRegistry), + new VersionCommand(ApplicationHeader), + HelpCommand, + }; + + public static async Task RunAsync(string[] args) + { + var context = new CommandContext(); + + // Launch debugger + if (context.IsEnvironmentVariableTruthy(Constants.EnvironmentVariables.GcmDebug, false)) + { + context.StdError.WriteLine("Waiting for debugger to be attached..."); + PlatformUtils.WaitForDebuggerAttached(); + + // Now the debugger is attached, break! + Debugger.Break(); + } + + // Enable tracing + if (context.TryGetEnvironmentVariable(Constants.EnvironmentVariables.GcmTrace, out string traceEnvar)) + { + if (traceEnvar.IsTruthy()) // Trace to stderr + { + context.Trace.AddListener(context.StdError); + } + else if (Path.IsPathRooted(traceEnvar) && // Trace to a file + TryCreateTextWriter(context, traceEnvar, out var fileWriter)) + { + context.Trace.AddListener(fileWriter); + } + else + { + context.StdError.WriteLine($"warning: cannot write trace output to {traceEnvar}"); + } + } + + // Enable sensitive tracing and show warning + if (context.IsEnvironmentVariableTruthy(Constants.EnvironmentVariables.GcmTraceSecrets, false)) + { + context.Trace.EnableSecretTracing = true; + context.StdError.WriteLine("Secret tracing is enabled. Trace output may contain sensitive information."); + } + + // Register all supported host providers + HostProviderRegistry.Register( + // TODO + ); + + // Trace the current version and program arguments + context.Trace.WriteLine($"{ApplicationHeader} '{string.Join(" ", args)}'"); + + if (args.Length == 0) + { + context.StdError.WriteLine("Missing command."); + HelpCommand.PrintUsage(context.StdError); + return -1; + } + + foreach (var cmd in Commands) + { + if (cmd.CanExecute(args)) + { + try + { + await cmd.ExecuteAsync(context, args); + return 0; + } + catch (Exception e) + { + if (e is AggregateException ae) + { + ae.Handle(x => WriteException(context, x)); + } + else + { + WriteException(context, e); + } + + return -1; + } + } + } + + context.StdError.WriteLine("Unrecognized command '{0}'.", args[0]); + HelpCommand.PrintUsage(context.StdError); + return -1; + } + + private static bool WriteException(ICommandContext context, Exception e) + { + context.StdError.WriteLine("fatal: {0}", e.Message); + if (e.InnerException != null) + { + context.StdError.WriteLine("fatal: {0}", e.InnerException.Message); + } + + return true; + } + + private static bool TryCreateTextWriter(ICommandContext context, string path, out TextWriter writer) + { + writer = null; + + try + { + var utf8NoBomEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + var stream = context.FileSystem.OpenFileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + writer = new StreamWriter(stream, utf8NoBomEncoding, 4096, leaveOpen: false); + } + catch + { + // Swallow all exceptions + } + + return writer != null; + } + } +} diff --git a/src/git-credential-manager/Program.cs b/src/git-credential-manager/Program.cs new file mode 100644 index 000000000..426d36058 --- /dev/null +++ b/src/git-credential-manager/Program.cs @@ -0,0 +1,13 @@ +using System; + +namespace Microsoft.Git.CredentialManager +{ + public static class Program + { + public static void Main(string[] args) + { + int exitCode = Application.RunAsync(args).ConfigureAwait(false).GetAwaiter().GetResult(); + Environment.Exit(exitCode); + } + } +} diff --git a/src/git-credential-manager/git-credential-manager.csproj b/src/git-credential-manager/git-credential-manager.csproj new file mode 100644 index 000000000..a5844a4fe --- /dev/null +++ b/src/git-credential-manager/git-credential-manager.csproj @@ -0,0 +1,16 @@ + + + + Exe + netcoreapp2.1 + win-x64;osx-x64 + git-credential-manager + Microsoft.Git.CredentialManager + latest + + + + + + + diff --git a/tests/Microsoft.Git.CredentialManager.Tests/Commands/HostProviderCommandBaseTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/Commands/HostProviderCommandBaseTests.cs new file mode 100644 index 000000000..2d1d8078b --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/Commands/HostProviderCommandBaseTests.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Git.CredentialManager.Commands; +using Moq; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests.Commands +{ + public class HostProviderCommandBaseTests + { + [Fact] + public async Task HostProviderCommandBase_ExecuteAsync_CallsExecuteInternalAyncWithCorrectArgs() + { + const string testProviderCredKey = "test-cred-key"; + const string testFinalCredKey = "git:test-cred-key"; + + var mockContext = new Mock(); + var mockProvider = new Mock(); + var mockHostRegistry = new Mock(); + + mockHostRegistry.Setup(x => x.GetProvider(It.IsAny())) + .Returns(mockProvider.Object) + .Verifiable(); + + mockProvider.Setup(x => x.IsSupported(It.IsAny())) + .Returns(true); + mockProvider.Setup(x => x.GetCredentialKey(It.IsAny())) + .Returns(testProviderCredKey); + + string standardIn = "protocol=test\nhost=example.com\npath=a/b/c\n\n"; + TextReader standardInReader = new StringReader(standardIn); + + mockContext.Setup(x => x.StdIn).Returns(standardInReader); + mockContext.Setup(x => x.Trace).Returns(new Mock().Object); + + HostProviderCommandBase testCommand = new TestCommand(mockHostRegistry.Object) + { + VerifyExecuteInternalAsync = (context, input, provider, credentialKey) => + { + Assert.Same(mockContext.Object, context); + Assert.Same(mockProvider.Object, provider); + Assert.Equal(testFinalCredKey, credentialKey); + Assert.Equal("test", input.Protocol); + Assert.Equal("example.com", input.Host); + Assert.Equal("a/b/c", input.Path); + } + }; + + await testCommand.ExecuteAsync(mockContext.Object, new string[0]); + } + + private class TestCommand : HostProviderCommandBase + { + public TestCommand(IHostProviderRegistry hostProviderRegistry) + : base(hostProviderRegistry) + { + } + + protected override string Name { get; } + + protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, + IHostProvider provider, string credentialKey) + { + VerifyExecuteInternalAsync(context, input, provider, credentialKey); + return Task.CompletedTask; + } + + public Action VerifyExecuteInternalAsync { get; set; } + } + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs new file mode 100644 index 000000000..0fb92c140 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs @@ -0,0 +1,59 @@ +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class GitCredentialTests + { + [Fact] + public void GitCredential_ToBase64String_ComplexUserPass_ReturnsCorrectString() + { + const string expected = "aGVsbG8tbXlfbmFtZSBpczpqb2huLmRvZTp0aGlzIWlzQVA0U1NXMFJEOiB3aXRoPyBfbG90cyBvZi8gY2hhcnM="; + const string testUserName = "hello-my_name is:john.doe"; + const string testPassword = "this!isAP4SSW0RD: with? _lots of/ chars"; + + var credential = new GitCredential(testUserName, testPassword); + string actual = credential.ToBase64String(); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GitCredential_ToBase64String_EmptyUserName_ReturnsCorrectString() + { + const string expected = "OmxldG1laW4xMjM="; + const string testUserName = ""; + const string testPassword = "letmein123"; + + var credential = new GitCredential(testUserName, testPassword); + string actual = credential.ToBase64String(); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GitCredential_ToBase64String_EmptyPassword_ReturnsCorrectString() + { + const string expected = "am9obi5kb2U6"; + const string testUserName = "john.doe"; + const string testPassword = ""; + + var credential = new GitCredential(testUserName, testPassword); + string actual = credential.ToBase64String(); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GitCredential_ToBase64String_EmptyCredential_ReturnsCorrectString() + { + const string expected = "Og=="; + const string testUserName = ""; + const string testPassword = ""; + + var credential = new GitCredential(testUserName, testPassword); + string actual = credential.ToBase64String(); + + Assert.Equal(expected, actual); + } + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs new file mode 100644 index 000000000..6da9e8f5c --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Moq; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class HostProviderRegistryTests + { + [Fact] + public void HostProviderRegistry_NoProviders_ThrowException() + { + var registry = new HostProviderRegistry(); + var input = new InputArguments(new Dictionary()); + + Assert.Throws(() => registry.GetProvider(input)); + } + + [Fact] + public void HostProviderRegistry_HasProviders_ReturnsSupportedProvider() + { + var registry = new HostProviderRegistry(); + var input = new InputArguments(new Dictionary()); + + var provider1 = new Mock(); + var provider2 = new Mock(); + var provider3 = new Mock(); + + provider1.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider2.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider3.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + + registry.Register(provider1.Object, provider2.Object, provider3.Object); + + IHostProvider result = registry.GetProvider(input); + + Assert.Same(provider2.Object, result); + } + + [Fact] + public void HostProviderRegistry_MultipleValidProviders_ReturnsFirstRegistered() + { + var registry = new HostProviderRegistry(); + var input = new InputArguments(new Dictionary()); + + var provider1 = new Mock(); + var provider2 = new Mock(); + var provider3 = new Mock(); + + provider1.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider3.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + + registry.Register(provider1.Object, provider2.Object, provider3.Object); + + IHostProvider result = registry.GetProvider(input); + + Assert.Same(provider1.Object, result); + } + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs new file mode 100644 index 000000000..01b7bdc72 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class InputArgumentsTests + { + [Fact] + public void InputArguments_Ctor_Null_ThrowsArgNullException() + { + Assert.Throws(() => new InputArguments(null)); + } + + [Fact] + public void InputArguments_CommonArguments_ValuePresent_ReturnsValues() + { + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + ["path"] = "an/example/path", + ["username"] = "john.doe", + ["password"] = "password123" + }; + + var inputArgs = new InputArguments(dict); + + Assert.Equal("https", inputArgs.Protocol); + Assert.Equal("example.com", inputArgs.Host); + Assert.Equal("an/example/path", inputArgs.Path); + Assert.Equal("john.doe", inputArgs.UserName); + Assert.Equal("password123", inputArgs.Password); + } + + [Fact] + public void InputArguments_CommonArguments_ValueMissing_ReturnsNull() + { + var dict = new Dictionary(); + + var inputArgs = new InputArguments(dict); + + Assert.Null(inputArgs.Protocol); + Assert.Null(inputArgs.Host); + Assert.Null(inputArgs.Path); + Assert.Null(inputArgs.UserName); + Assert.Null(inputArgs.Password); + } + + [Fact] + public void InputArguments_OtherArguments() + { + var dict = new Dictionary + { + ["foo"] = "bar" + }; + + var inputArgs = new InputArguments(dict); + + Assert.Equal("bar", inputArgs["foo"]); + Assert.Equal("bar", inputArgs.GetArgumentOrDefault("foo")); + } + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/Microsoft.Git.CredentialManager.Tests.csproj b/tests/Microsoft.Git.CredentialManager.Tests/Microsoft.Git.CredentialManager.Tests.csproj new file mode 100644 index 000000000..9dcdd58b4 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/Microsoft.Git.CredentialManager.Tests.csproj @@ -0,0 +1,23 @@ + + + + netcoreapp2.1 + + false + + Microsoft.Git.CredentialManager.Tests + + + + + + + + + + + + + + + diff --git a/tests/Microsoft.Git.CredentialManager.Tests/PlatformFactAttribute.cs b/tests/Microsoft.Git.CredentialManager.Tests/PlatformFactAttribute.cs new file mode 100644 index 000000000..6244da6c0 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/PlatformFactAttribute.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class PlatformFactAttribute : FactAttribute + { + public PlatformFactAttribute(params Platform[] platforms) + { + if (platforms.Contains(Platform.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + if (platforms.Contains(Platform.MacOS) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return; + } + + if (platforms.Contains(Platform.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return; + } + + Skip = "Test not supported on this platform."; + } + } + + public enum Platform + { + Windows, + MacOS, + Linux, + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/StreamExtensionsTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/StreamExtensionsTests.cs new file mode 100644 index 000000000..be2738249 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/StreamExtensionsTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class StreamExtensionsTests + { + [Fact] + public void StreamExtensions_ReadDictionary_EmptyString_ReturnsEmptyDictionary() + { + string input = string.Empty; + + var output = ReadStringStream(input, StreamExtensions.ReadDictionary); + + Assert.NotNull(output); + Assert.Equal(0, output.Count); + } + + [Fact] + public void StreamExtensions_ReadDictionary_TerminatedLF_ReturnsDictionary() + { + string input = "a=1\nb=2\nc=3\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + Assert.Contains(KeyValuePair.Create("a", "1"), output); + Assert.Contains(KeyValuePair.Create("b", "2"), output); + Assert.Contains(KeyValuePair.Create("c", "3"), output); + } + + [Fact] + public void StreamExtensions_ReadDictionary_TerminatedCRLF_ReturnsDictionary() + { + string input = "a=1\r\nb=2\r\nc=3\r\n\r\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + Assert.Contains(KeyValuePair.Create("a", "1"), output); + Assert.Contains(KeyValuePair.Create("b", "2"), output); + Assert.Contains(KeyValuePair.Create("c", "3"), output); + } + + [Fact] + public void StreamExtensions_ReadDictionary_CaseSensitive_ReturnsDictionaryWithMultipleEntries() + { + string input = "a=1\nA=2\n\n"; + + var output = ReadStringStream(input, x => StreamExtensions.ReadDictionary(x, StringComparer.Ordinal)); + + Assert.NotNull(output); + Assert.Equal(2, output.Count); + Assert.Contains(KeyValuePair.Create("a", "1"), output); + Assert.Contains(KeyValuePair.Create("A", "2"), output); + } + + [Fact] + public void StreamExtensions_ReadDictionary_CaseInsensitive_ReturnsDictionaryWithLastValue() + { + string input = "a=1\nA=2\n\n"; + + var output = ReadStringStream(input, x => StreamExtensions.ReadDictionary(x, StringComparer.OrdinalIgnoreCase)); + + Assert.NotNull(output); + Assert.Equal(1, output.Count); + Assert.Contains(KeyValuePair.Create("a", "2"), output); + } + + [Fact] + public void StreamExtensions_ReadDictionary_Spaces_ReturnsCorrectKeysAndValues() + { + string input = "key a=value 1\n key b = 2 \nkey\tc\t=\t3\t\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + Assert.Contains(KeyValuePair.Create("key a", "value 1"), output); + Assert.Contains(KeyValuePair.Create(" key b ", " 2 "), output); + Assert.Contains(KeyValuePair.Create("key\tc\t", "\t3\t"), output); + } + + [Fact] + public void StreamExtensions_ReadDictionary_EqualsInValues_ReturnsCorrectKeysAndValues() + { + string input = "a=value=1\nb=value=2\nc=value=3\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + Assert.Contains(KeyValuePair.Create("a", "value=1"), output); + Assert.Contains(KeyValuePair.Create("b", "value=2"), output); + Assert.Contains(KeyValuePair.Create("c", "value=3"), output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_EmptyDictionary_WritesLineTerminator() + { + var input = new Dictionary(); + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary); + + Assert.Equal(Environment.NewLine, output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_Entries_WritesKVPsAndLineTerminator() + { + var input = new Dictionary + { + ["a"] = "1", + ["b"] = "2", + ["c"] = "3" + }; + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary); + + Assert.Equal("a=1\nb=2\nc=3\n\n", output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_EntriesWithSpaces_WritesKVPsAndLineTerminator() + { + var input = new Dictionary + { + ["key a"] = "value 1", + [" key b "] = " value 2 ", + ["\tvalue\tc\t"] = "\t3\t" + }; + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary); + + Assert.Equal("key a=value 1\n key b = value 2 \n\tvalue\tc\t=\t3\t\n\n", output); + } + + #region Helpers + + private static IDictionary ReadStringStream(string input, Func> func) + { + IDictionary output; + using (var reader = new StringReader(input)) + { + output = func(reader); + } + + return output; + } + + private static string WriteStringStream(IDictionary input, Action> action) + { + var output = new StringBuilder(); + using (var writer = new StringWriter(output)) + { + action(writer, input); + } + + return output.ToString(); + } + + #endregion + } +} diff --git a/tests/Microsoft.Git.CredentialManager.Tests/StringExtensionsTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/StringExtensionsTests.cs new file mode 100644 index 000000000..35b6906b6 --- /dev/null +++ b/tests/Microsoft.Git.CredentialManager.Tests/StringExtensionsTests.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class StringExtensionsTests + { + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("tRuE", true)] + [InlineData("yes", true)] + [InlineData("YES", true)] + [InlineData("yEs", true)] + [InlineData("on", true)] + [InlineData("ON", true)] + [InlineData("oN", true)] + [InlineData("1", true)] + [InlineData("false", false)] + [InlineData("i am a random string", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("\t", false)] + [InlineData(null, false)] + public void StringExtensions_IsTruthy(string input, bool expected) + { + if (expected) + { + Assert.True(StringExtensions.IsTruthy(input)); + } + else + { + Assert.False(StringExtensions.IsTruthy(input)); + } + } + } +}