diff --git a/docs/architecture.md b/docs/architecture.md index 0aff15287..e0a31b962 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -202,7 +202,7 @@ the input arguments from Git - `://[/]` - no username is included even if present. Host providers are queried in turn, by priority (then registration order) via -the `IHostProvider.IsSupported(InputArguments)` method and passed the input +the `IHostProvider.IsSupported(GitRequest)` method and passed the input received from Git. If the provider recognises the request, for example by a matching known host name, they can return `true`. If the provider wants to cancel and abort an authentication request, for example if this is a HTTP (not @@ -213,12 +213,12 @@ Host providers can also be queried via the `IHostProvider.IsSupported(HttpRespon method and passed the response message from a HEAD call made to the remote URI. This is useful for detecting on-premises instances based on header values. GCM will only query a provider via this method overload if no other provider at the -same registration priority has returned `true` to the `InputArguments` overload. +same registration priority has returned `true` to the `GitRequest` overload. Depending on the request from Git, one of `GetCredentialAsync` (for `get` requests), `StoreCredentialAsync` (for `store` requests) or `EraseCredentialAsync` (for `erase` requests) will be called. The argument -`InputArguments` contains the request information passed over standard input +`GitRequest` contains the request information passed over standard input from Git/the caller; the same as was passed to `IsSupported`. The return value for the `get` operation must be an `ICredential` that Git can diff --git a/docs/hostprovider.md b/docs/hostprovider.md index 25aaf3d85..5d0c9df44 100644 --- a/docs/hostprovider.md +++ b/docs/hostprovider.md @@ -94,18 +94,18 @@ request in a standard way. ### 2.2. Handling Requests -The `IsSupported(InputArguments)` method will be called on all registered host +The `IsSupported(GitRequest)` method will be called on all registered host providers in-turn on the invocation of a `get`, `store`, or `erase` request. The first host provider to return `true` will be called upon to handle the specific request. If the user has overridden the host provider selection process, a specific host provider may be selected instead, and the -`IsSupported(InputArguments)` method will NOT be called. +`IsSupported(GitRequest)` method will NOT be called. This method MUST return `true` if and only if the provider understands the request and can serve or handle the request. If the provider does not know how to handle the request it MUST return `false` instead. -If no host provider returns `true` to a call to the `IsSupported(InputArguments)` +If no host provider returns `true` to a call to the `IsSupported(GitRequest)` method for a each host provider priority level, then a HTTP HEAD request will be made to the remote URL and each host provider will be be called via the `IsSupported(HttpResponseMessage)` method. A host provider SHOULD use this call diff --git a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs index 86fcd0c27..237db429c 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs @@ -45,14 +45,14 @@ public class BitbucketHostProviderTest [InlineData("https", "BITBUCKET.My-Company-Server.Com", false)] public void BitbucketHostProvider_IsSupported(string protocol, string host, bool expected) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = host, }); var provider = new BitbucketHostProvider(new TestCommandContext()); - Assert.Equal(expected, provider.IsSupported(input)); + Assert.Equal(expected, provider.IsSupported(request)); } [Theory] @@ -60,21 +60,21 @@ public void BitbucketHostProvider_IsSupported(string protocol, string host, bool [InlineData("Basic realm=\"GitSponge\"", false)] public void BitbucketHostProvider_IsSupported_WWWAuth(string wwwauth, bool expected) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["wwwauth"] = wwwauth, }); var provider = new BitbucketHostProvider(new TestCommandContext()); - Assert.Equal(expected, provider.IsSupported(input)); + Assert.Equal(expected, provider.IsSupported(request)); } [Fact] public void BitbucketHostProvider_IsSupported_FailsForNullInput() { - InputArguments input = null; + GitRequest request = null; var provider = new BitbucketHostProvider(new TestCommandContext()); - Assert.False(provider.IsSupported(input)); + Assert.False(provider.IsSupported(request)); } [Fact] @@ -91,14 +91,14 @@ public void BitbucketHostProvider_IsSupported_FailsForNullHttpResponseMessage() [InlineData(null, null, false)] public void BitbucketHostProvider_IsSupported_HttpResponseMessage(string header, string value, bool expected) { - var input = new HttpResponseMessage(); + var request = new HttpResponseMessage(); if (header != null) { - input.Headers.Add(header, value); + request.Headers.Add(header, value); } var provider = new BitbucketHostProvider(new TestCommandContext()); - Assert.Equal(expected, provider.IsSupported(input)); + Assert.Equal(expected, provider.IsSupported(request)); } [Theory] @@ -107,7 +107,7 @@ public void BitbucketHostProvider_IsSupported_HttpResponseMessage(string header, public async Task BitbucketHostProvider_GetCredentialAsync_Valid_Stored_Basic( string protocol, string host, string username, string password) { - InputArguments input = MockInput(protocol, host, username); + GitRequest request = MockInput(protocol, host, username); var context = new TestCommandContext(); @@ -115,20 +115,20 @@ public async Task BitbucketHostProvider_GetCredentialAsync_Valid_Stored_Basic( { MockDCSSOEnabled(); } - MockStoredAccount(context, input, password); - MockRemoteBasicValid(input, password); - // HACK rebase MockRemoteBasicAuthAccountIsValidNo2FA(bitbucketApi, input, password, username); + MockStoredAccount(context, request, password); + MockRemoteBasicValid(request, password); + // HACK rebase MockRemoteBasicAuthAccountIsValidNo2FA(bitbucketApi, request, password, username); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(password, credential.Password); // Verify bitbucket.org credentials were validated - VerifyValidateBasicAuthCredentialsRan(input, password); + VerifyValidateBasicAuthCredentialsRan(request, password); // Verify DC/Server credentials were not validated // Stored credentials so don't ask for more @@ -146,7 +146,7 @@ public Mock GetBitbucketApi() public async Task BitbucketHostProvider_GetCredentialAsync_Valid_Stored_OAuth( string protocol, string host, string username, string token) { - InputArguments input = MockInput(protocol, host, username); + GitRequest request = MockInput(protocol, host, username); var context = new TestCommandContext(); @@ -154,19 +154,19 @@ public async Task BitbucketHostProvider_GetCredentialAsync_Valid_Stored_OAuth( { MockDCSSOEnabled(); } - MockStoredAccount(context, input, token); - MockRemoteAccessTokenValid(input, token); + MockStoredAccount(context, request, token); + MockRemoteAccessTokenValid(request, token); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(token, credential.Password); // Verify bitbucket.org credentials were validated - VerifyValidateAccessTokenRan(input, token); + VerifyValidateAccessTokenRan(request, token); // Stored credentials so don't ask for more VerifyInteractiveAuthNeverRan(); @@ -186,22 +186,22 @@ private void MockDCSSOEnabled() public async Task BitbucketHostProvider_GetCredentialAsync_Valid_New_Basic( string protocol, string host, string username, string password) { - InputArguments input = MockInput(protocol, host, username); + GitRequest request = MockInput(protocol, host, username); var context = new TestCommandContext(); - MockPromptBasic(input, password); - MockRemoteBasicValid(input, password); + MockPromptBasic(request, password); + MockRemoteBasicValid(request, password); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(password, credential.Password); - VerifyInteractiveAuthRan(input); + VerifyInteractiveAuthRan(request); } [Theory] @@ -210,26 +210,26 @@ public async Task BitbucketHostProvider_GetCredentialAsync_Valid_New_Basic( public async Task BitbucketHostProvider_GetCredentialAsync_Valid_New_OAuth( string protocol, string host, string username, string refreshToken, string accessToken) { - InputArguments input = MockInput(protocol, host, username); + GitRequest request = MockInput(protocol, host, username); var context = new TestCommandContext(); - MockPromptOAuth(input); - MockRemoteOAuthTokenCreate(input, accessToken, refreshToken); - MockRemoteAccessTokenValid(input, accessToken); + MockPromptOAuth(request); + MockRemoteOAuthTokenCreate(request, accessToken, refreshToken); + MockRemoteAccessTokenValid(request, accessToken); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(accessToken, credential.Password); - VerifyInteractiveAuthRan(input); - VerifyOAuthFlowRan(input, accessToken); - VerifyValidateAccessTokenRan(input, accessToken); - VerifyOAuthRefreshTokenStored(context, input, refreshToken); + VerifyInteractiveAuthRan(request); + VerifyOAuthFlowRan(request, accessToken); + VerifyValidateAccessTokenRan(request, accessToken); + VerifyOAuthRefreshTokenStored(context, request, refreshToken); } [Theory] @@ -238,25 +238,25 @@ public async Task BitbucketHostProvider_GetCredentialAsync_Valid_New_OAuth( public async Task BitbucketHostProvider_GetCredentialAsync_MissingAT_OAuth_Refresh( string protocol, string host, string username, string refreshToken, string accessToken) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); // AT has does not exist, but RT is still valid - MockStoredRefreshToken(context, input, refreshToken); - MockRemoteAccessTokenValid(input, accessToken); - MockRemoteRefreshTokenValid(input, refreshToken, accessToken); + MockStoredRefreshToken(context, request, refreshToken); + MockRemoteAccessTokenValid(request, accessToken); + MockRemoteRefreshTokenValid(request, refreshToken, accessToken); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(accessToken, credential.Password); - VerifyValidateAccessTokenRan(input, accessToken); - VerifyOAuthRefreshRan(input, refreshToken); + VerifyValidateAccessTokenRan(request, accessToken); + VerifyOAuthRefreshRan(request, refreshToken); VerifyInteractiveAuthNeverRan(); } @@ -266,28 +266,28 @@ public async Task BitbucketHostProvider_GetCredentialAsync_MissingAT_OAuth_Refre public async Task BitbucketHostProvider_GetCredentialAsync_ExpiredAT_OAuth_Refresh( string protocol, string host, string username, string refreshToken, string expiredAccessToken, string accessToken) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); // AT exists but has expired, but RT is still valid - MockStoredAccount(context, input, expiredAccessToken); - MockRemoteAccessTokenExpired(input, expiredAccessToken); + MockStoredAccount(context, request, expiredAccessToken); + MockRemoteAccessTokenExpired(request, expiredAccessToken); - MockStoredRefreshToken(context, input, refreshToken); - MockRemoteAccessTokenValid(input, accessToken); - MockRemoteRefreshTokenValid(input, refreshToken, accessToken); + MockStoredRefreshToken(context, request, refreshToken); + MockRemoteAccessTokenValid(request, accessToken); + MockRemoteRefreshTokenValid(request, refreshToken, accessToken); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(accessToken, credential.Password); - VerifyValidateAccessTokenRan(input, accessToken); - VerifyOAuthRefreshRan(input, refreshToken); + VerifyValidateAccessTokenRan(request, accessToken); + VerifyOAuthRefreshRan(request, refreshToken); VerifyInteractiveAuthNeverRan(); } @@ -297,25 +297,25 @@ public async Task BitbucketHostProvider_GetCredentialAsync_ExpiredAT_OAuth_Refre public async Task BitbucketHostProvider_GetCredentialAsync_PreconfiguredMode_OAuth_ValidRT_IsRespected( string protocol, string host, string username, string refreshToken, string accessToken) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); context.Environment.Variables.Add(BitbucketConstants.EnvironmentVariables.AuthenticationModes, "oauth"); // We have a stored RT so we can just use that without any prompts - MockStoredRefreshToken(context, input, refreshToken); - MockRemoteAccessTokenValid(input, accessToken); - MockRemoteRefreshTokenValid(input, refreshToken, accessToken); + MockStoredRefreshToken(context, request, refreshToken); + MockRemoteAccessTokenValid(request, accessToken); + MockRemoteRefreshTokenValid(request, refreshToken, accessToken); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); VerifyInteractiveAuthNeverRan(); - VerifyOAuthRefreshRan(input, refreshToken); + VerifyOAuthRefreshRan(request, refreshToken); } [Theory] @@ -323,28 +323,28 @@ public async Task BitbucketHostProvider_GetCredentialAsync_PreconfiguredMode_OAu public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredentials_OAuth_IsRespected( string protocol, string host, string username, string storedToken, string newToken, string refreshToken) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); context.Environment.Variables.Add( BitbucketConstants.EnvironmentVariables.AlwaysRefreshCredentials, bool.TrueString); // User has stored access token that we shouldn't use - RT should be used to mint new AT - MockStoredAccount(context, input, storedToken); - MockStoredRefreshToken(context, input, refreshToken); - MockRemoteAccessTokenValid(input, newToken); - MockRemoteRefreshTokenValid(input, refreshToken, newToken); + MockStoredAccount(context, request, storedToken); + MockStoredRefreshToken(context, request, refreshToken); + MockRemoteAccessTokenValid(request, newToken); + MockRemoteRefreshTokenValid(request, refreshToken, newToken); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(newToken, credential.Password); VerifyInteractiveAuthNeverRan(); - VerifyOAuthRefreshRan(input, refreshToken); + VerifyOAuthRefreshRan(request, refreshToken); } [Theory] @@ -355,25 +355,25 @@ public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredenti public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredentials_Basic_IsRespected( string protocol, string host, string username, string storedPassword, string freshPassword) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); context.Environment.Variables.Add( BitbucketConstants.EnvironmentVariables.AlwaysRefreshCredentials, bool.TrueString); // User has stored password that we shouldn't use - MockStoredAccount(context, input, storedPassword); - MockPromptBasic(input, freshPassword); + MockStoredAccount(context, request, storedPassword); + MockPromptBasic(request, freshPassword); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(username, credential.Account); Assert.Equal(freshPassword, credential.Password); - VerifyInteractiveAuthRan(input); + VerifyInteractiveAuthRan(request); } [Theory] @@ -391,7 +391,7 @@ public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredenti [InlineData("https", "bitbucket.org", null, CloudConstants.DotOrgAuthenticationModes)] public async Task BitbucketHostProvider_GetSupportedAuthenticationModes(string protocol, string host, string bitbucketAuthModes, AuthenticationModes expectedModes) { - var input = MockInput(protocol, host, null); + var request = MockInput(protocol, host, null); var context = new TestCommandContext(); if (bitbucketAuthModes != null) @@ -399,9 +399,9 @@ public async Task BitbucketHostProvider_GetSupportedAuthenticationModes(string p context.Environment.Variables.Add(BitbucketConstants.EnvironmentVariables.AuthenticationModes, bitbucketAuthModes); } - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); - AuthenticationModes actualModes = await provider.GetSupportedAuthenticationModesAsync(input); + AuthenticationModes actualModes = await provider.GetSupportedAuthenticationModesAsync(request); Assert.Equal(expectedModes, actualModes); } @@ -410,15 +410,15 @@ public async Task BitbucketHostProvider_GetSupportedAuthenticationModes(string p [InlineData("https", DC_SERVER_HOST, "jsquire")] public async Task BitbucketHostProvider_StoreCredentialAsync(string protocol, string host, string username) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); Assert.Equal(0, context.CredentialStore.Count); - await provider.StoreCredentialAsync(input); + await provider.StoreCredentialAsync(request); Assert.Equal(1, context.CredentialStore.Count); } @@ -427,17 +427,17 @@ public async Task BitbucketHostProvider_StoreCredentialAsync(string protocol, st [InlineData("https", DC_SERVER_HOST, "jsquire", "password")] public async Task BitbucketHostProvider_EraseCredentialAsync(string protocol, string host, string username, string password) { - var input = MockInput(protocol, host, username); + var request = MockInput(protocol, host, username); var context = new TestCommandContext(); - MockStoredAccount(context, input, password); + MockStoredAccount(context, request, password); - var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(input, bitbucketApi).Object); + var provider = new BitbucketHostProvider(context, bitbucketAuthentication.Object, MockRestApiRegistry(request, bitbucketApi).Object); Assert.Equal(1, context.CredentialStore.Count); - await provider.EraseCredentialAsync(input); + await provider.EraseCredentialAsync(request); Assert.Equal(0, context.CredentialStore.Count); } @@ -446,9 +446,9 @@ public async Task BitbucketHostProvider_EraseCredentialAsync(string protocol, st #region Test helpers - private static InputArguments MockInput(string protocol, string host, string username) + private static GitRequest MockInput(string protocol, string host, string username) { - return new InputArguments(new Dictionary + return new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = host, @@ -456,10 +456,10 @@ private static InputArguments MockInput(string protocol, string host, string use }); } - private void VerifyOAuthFlowRan(InputArguments input, string token) + private void VerifyOAuthFlowRan(GitRequest request, string token) { // Get new access token and refresh token - bitbucketAuthentication.Verify(m => m.CreateOAuthCredentialsAsync(input), Times.Once); + bitbucketAuthentication.Verify(m => m.CreateOAuthCredentialsAsync(request), Times.Once); // Check access token works/resolve username bitbucketApi.Verify(m => m.GetUserInformationAsync(null, token, true), Times.Once); @@ -471,23 +471,23 @@ private void VerifyValidateBasicAuthCredentialsNeverRan() bitbucketApi.Verify(m => m.GetUserInformationAsync(It.IsAny(), It.IsAny(), false), Times.Never); } - private void VerifyValidateBasicAuthCredentialsRan(InputArguments input, string password) + private void VerifyValidateBasicAuthCredentialsRan(GitRequest request, string password) { // Check username/password works - bitbucketApi.Verify(m => m.GetUserInformationAsync(input.UserName, password, false), Times.Once); + bitbucketApi.Verify(m => m.GetUserInformationAsync(request.UserName, password, false), Times.Once); } - private void VerifyValidateAccessTokenRan(InputArguments input, string token) + private void VerifyValidateAccessTokenRan(GitRequest request, string token) { // Check tokens works bitbucketApi.Verify(m => m.GetUserInformationAsync(null, token, true), Times.Once); } - private void VerifyInteractiveAuthRan(InputArguments input) + private void VerifyInteractiveAuthRan(GitRequest request) { - var remoteUri = input.GetRemoteUri(); + var remoteUri = request.GetRemoteUri(); - bitbucketAuthentication.Verify(m => m.GetCredentialsAsync(remoteUri, input.UserName, It.IsAny()), Times.Once); + bitbucketAuthentication.Verify(m => m.GetCredentialsAsync(remoteUri, request.UserName, It.IsAny()), Times.Once); } private void VerifyInteractiveAuthNeverRan() @@ -495,52 +495,52 @@ private void VerifyInteractiveAuthNeverRan() bitbucketAuthentication.Verify(m => m.GetCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } - private void VerifyOAuthRefreshRan(InputArguments input, string refreshToken) + private void VerifyOAuthRefreshRan(GitRequest request, string refreshToken) { // Check refresh was called - bitbucketAuthentication.Verify(m => m.RefreshOAuthCredentialsAsync(input, refreshToken), Times.Once); + bitbucketAuthentication.Verify(m => m.RefreshOAuthCredentialsAsync(request, refreshToken), Times.Once); } - private void MockRemoteRefreshTokenValid(InputArguments input, string refreshToken, string accessToken) + private void MockRemoteRefreshTokenValid(GitRequest request, string refreshToken, string accessToken) { - bitbucketAuthentication.Setup(m => m.RefreshOAuthCredentialsAsync(input, refreshToken)).ReturnsAsync(new OAuth2TokenResult(accessToken, "access_token")); + bitbucketAuthentication.Setup(m => m.RefreshOAuthCredentialsAsync(request, refreshToken)).ReturnsAsync(new OAuth2TokenResult(accessToken, "access_token")); } - private void MockPromptBasic(InputArguments input, string password) + private void MockPromptBasic(GitRequest request, string password) { - var remoteUri = input.GetRemoteUri(); - bitbucketAuthentication.Setup(m => m.GetCredentialsAsync(remoteUri, input.UserName, It.IsAny())) - .ReturnsAsync(new CredentialsPromptResult(AuthenticationModes.Basic, new TestCredential(input.Host, input.UserName, password))); + var remoteUri = request.GetRemoteUri(); + bitbucketAuthentication.Setup(m => m.GetCredentialsAsync(remoteUri, request.UserName, It.IsAny())) + .ReturnsAsync(new CredentialsPromptResult(AuthenticationModes.Basic, new TestCredential(request.Host, request.UserName, password))); } - private void MockPromptOAuth(InputArguments input) + private void MockPromptOAuth(GitRequest request) { - var remoteUri = input.GetRemoteUri(); - bitbucketAuthentication.Setup(m => m.GetCredentialsAsync(remoteUri, input.UserName, It.IsAny())) + var remoteUri = request.GetRemoteUri(); + bitbucketAuthentication.Setup(m => m.GetCredentialsAsync(remoteUri, request.UserName, It.IsAny())) .ReturnsAsync(new CredentialsPromptResult(AuthenticationModes.OAuth)); } - private void MockRemoteBasicValid(InputArguments input, string password) + private void MockRemoteBasicValid(GitRequest request, string password) { var userInfo = new Mock(MockBehavior.Strict); - userInfo.Setup(ui => ui.UserName).Returns(input.UserName); + userInfo.Setup(ui => ui.UserName).Returns(request.UserName); // Basic - bitbucketApi.Setup(x => x.GetUserInformationAsync(input.UserName, password, false)) + bitbucketApi.Setup(x => x.GetUserInformationAsync(request.UserName, password, false)) .ReturnsAsync(new RestApiResult(System.Net.HttpStatusCode.OK, userInfo.Object)); } - private void MockRemoteAccessTokenExpired(InputArguments input, string token) + private void MockRemoteAccessTokenExpired(GitRequest request, string token) { // OAuth bitbucketApi.Setup(x => x.GetUserInformationAsync(null, token, true)) .ReturnsAsync(new RestApiResult(System.Net.HttpStatusCode.Unauthorized)); } - private void MockRemoteAccessTokenValid(InputArguments input, string token) + private void MockRemoteAccessTokenValid(GitRequest request, string token) { var userInfo = new Mock(MockBehavior.Strict); - userInfo.Setup(ui => ui.UserName).Returns(input.UserName); + userInfo.Setup(ui => ui.UserName).Returns(request.UserName); // OAuth bitbucketApi.Setup(x => x.GetUserInformationAsync(null, token, true)) @@ -553,41 +553,41 @@ private static void MockRemoteOAuthAccountIsInvalid(Mock bitb bitbucketApi.Setup(x => x.GetUserInformationAsync(null, It.IsAny(), true)).ReturnsAsync(new RestApiResult(System.Net.HttpStatusCode.BadRequest)); } - private static void MockStoredAccount(TestCommandContext context, InputArguments input, string password) + private static void MockStoredAccount(TestCommandContext context, GitRequest request, string password) { - var remoteUri = input.GetRemoteUri(); + var remoteUri = request.GetRemoteUri(); var remoteUrl = remoteUri.AbsoluteUri.Substring(0, remoteUri.AbsoluteUri.Length - 1); - context.CredentialStore.Add(remoteUrl, new TestCredential(input.Host, input.UserName, password)); + context.CredentialStore.Add(remoteUrl, new TestCredential(request.Host, request.UserName, password)); } - private static void MockStoredRefreshToken(TestCommandContext context, InputArguments input, string token) + private static void MockStoredRefreshToken(TestCommandContext context, GitRequest request, string token) { - var remoteUri = input.GetRemoteUri(); + var remoteUri = request.GetRemoteUri(); var refreshService = BitbucketHostProvider.GetRefreshTokenServiceName(remoteUri); - context.CredentialStore.Add(refreshService, new TestCredential(refreshService, input.UserName, token)); + context.CredentialStore.Add(refreshService, new TestCredential(refreshService, request.UserName, token)); } - private void MockRemoteOAuthTokenCreate(InputArguments input, string accessToken, string refreshToken) + private void MockRemoteOAuthTokenCreate(GitRequest request, string accessToken, string refreshToken) { - bitbucketAuthentication.Setup(x => x.CreateOAuthCredentialsAsync(input)) + bitbucketAuthentication.Setup(x => x.CreateOAuthCredentialsAsync(request)) .ReturnsAsync(new OAuth2TokenResult(accessToken, "access_token") { RefreshToken = refreshToken }); } - private void VerifyOAuthRefreshTokenStored(TestCommandContext context, InputArguments input, string refreshToken) + private void VerifyOAuthRefreshTokenStored(TestCommandContext context, GitRequest request, string refreshToken) { - var remoteUri = input.GetRemoteUri(); + var remoteUri = request.GetRemoteUri(); string refreshService = BitbucketHostProvider.GetRefreshTokenServiceName(remoteUri); - bool result = context.CredentialStore.TryGet(refreshService, input.UserName, out var credential); + bool result = context.CredentialStore.TryGet(refreshService, request.UserName, out var credential); Assert.True(result); Assert.Equal(refreshToken, credential.Password); } - private static Mock> MockRestApiRegistry(InputArguments input, Mock bitbucketApi) + private static Mock> MockRestApiRegistry(GitRequest request, Mock bitbucketApi) { var restApiRegistry = new Mock>(MockBehavior.Strict); - restApiRegistry.Setup(rar => rar.Get(input)).Returns(bitbucketApi.Object); + restApiRegistry.Setup(rar => rar.Get(request)).Returns(bitbucketApi.Object); return restApiRegistry; } diff --git a/src/shared/Atlassian.Bitbucket.Tests/BitbucketRestApiRegistryTest.cs b/src/shared/Atlassian.Bitbucket.Tests/BitbucketRestApiRegistryTest.cs index ee4499dfb..92f2943d6 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/BitbucketRestApiRegistryTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/BitbucketRestApiRegistryTest.cs @@ -17,7 +17,7 @@ public void BitbucketRestApiRegistry_Get_ReturnsCloudApi_ForBitbucketOrg() settings.Setup(s => s.RemoteUri).Returns(new System.Uri("https://bitbucket.org")); context.Setup(c => c.Settings).Returns(settings.Object); - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "bitbucket.org", @@ -25,7 +25,7 @@ public void BitbucketRestApiRegistry_Get_ReturnsCloudApi_ForBitbucketOrg() // When var registry = new BitbucketRestApiRegistry(context.Object); - var api = registry.Get(input); + var api = registry.Get(request); // Then Assert.NotNull(api); @@ -40,7 +40,7 @@ public void BitbucketRestApiRegistry_Get_ReturnsDataCenterApi_ForBitbucketDC() settings.Setup(s => s.RemoteUri).Returns(new System.Uri("https://example.com")); context.Setup(c => c.Settings).Returns(settings.Object); - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "http", ["host"] = "example.com", @@ -48,7 +48,7 @@ public void BitbucketRestApiRegistry_Get_ReturnsDataCenterApi_ForBitbucketDC() // When var registry = new BitbucketRestApiRegistry(context.Object); - var api = registry.Get(input); + var api = registry.Get(request); // Then Assert.NotNull(api); diff --git a/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs b/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs index 1a6866fb6..fbf637181 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs @@ -82,13 +82,13 @@ public void BitbucketOAuth2Client_GetRefreshTokenServiceName(string protocol, st { var trace2 = new NullTrace2(); var client = new Bitbucket.Cloud.BitbucketOAuth2Client(httpClient.Object, settings.Object, trace2); - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = host, ["username"] = username }); - Assert.Equal(expectedResult, client.GetRefreshTokenServiceName(input)); + Assert.Equal(expectedResult, client.GetRefreshTokenServiceName(request)); } diff --git a/src/shared/Atlassian.Bitbucket.Tests/OAuth2ClientRegistryTest.cs b/src/shared/Atlassian.Bitbucket.Tests/OAuth2ClientRegistryTest.cs index c7bc06917..5f6848d4b 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/OAuth2ClientRegistryTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/OAuth2ClientRegistryTest.cs @@ -28,7 +28,7 @@ public void BitbucketRestApiRegistry_Get_ReturnsCloudOAuth2Client() MockSettingOverride(CloudConstants.EnvironmentVariables.OAuthClientSecret, CloudConstants.GitConfiguration.Credential.OAuthClientSecret, "never used", false); MockSettingOverride(CloudConstants.EnvironmentVariables.OAuthRedirectUri, CloudConstants.GitConfiguration.Credential.OAuthRedirectUri, "never used", false); MockHttpClientFactory(); - var input = MockInputArguments(host); + var input = MockGitRequest(host); // When var registry = new OAuth2ClientRegistry(context.Object); @@ -52,7 +52,7 @@ public void BitbucketRestApiRegistry_Get_ReturnsDataCenterOAuth2Client_ForBitbuc MockSettingOverride(DataCenterConstants.EnvironmentVariables.OAuthClientSecret, DataCenterConstants.GitConfiguration.Credential.OAuthClientSecret, "", true); ; MockSettingOverride(DataCenterConstants.EnvironmentVariables.OAuthRedirectUri, DataCenterConstants.GitConfiguration.Credential.OAuthRedirectUri, "never used", false); MockHttpClientFactory(); - var input = MockInputArguments(host); + var input = MockGitRequest(host); // When var registry = new OAuth2ClientRegistry(context.Object); @@ -64,9 +64,9 @@ public void BitbucketRestApiRegistry_Get_ReturnsDataCenterOAuth2Client_ForBitbuc } - private static InputArguments MockInputArguments(string host) + private static GitRequest MockGitRequest(string host) { - return new InputArguments(new Dictionary + return new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = host, diff --git a/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs b/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs index 0a442cea7..1796bbfc0 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs @@ -26,9 +26,9 @@ public enum AuthenticationModes public interface IBitbucketAuthentication : IDisposable { Task GetCredentialsAsync(Uri targetUri, string userName, AuthenticationModes modes); - Task CreateOAuthCredentialsAsync(InputArguments input); - Task RefreshOAuthCredentialsAsync(InputArguments input, string refreshToken); - string GetRefreshTokenServiceName(InputArguments input); + Task CreateOAuthCredentialsAsync(GitRequest request); + Task RefreshOAuthCredentialsAsync(GitRequest request, string refreshToken); + string GetRefreshTokenServiceName(GitRequest request); } public class CredentialsPromptResult @@ -249,7 +249,7 @@ private async Task GetCredentialsViaHelperAsync( } } - public async Task CreateOAuthCredentialsAsync(InputArguments input) + public async Task CreateOAuthCredentialsAsync(GitRequest request) { ThrowIfUserInteractionDisabled(); @@ -260,22 +260,22 @@ public async Task CreateOAuthCredentialsAsync(InputArguments }; var browser = new OAuth2SystemWebBrowser(Context.SessionManager, browserOptions); - var oauth2Client = _oauth2ClientRegistry.Get(input); + var oauth2Client = _oauth2ClientRegistry.Get(request); var authCodeResult = await oauth2Client.GetAuthorizationCodeAsync(browser, CancellationToken.None); return await oauth2Client.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None); } - public async Task RefreshOAuthCredentialsAsync(InputArguments input, string refreshToken) + public async Task RefreshOAuthCredentialsAsync(GitRequest request, string refreshToken) { - var client = _oauth2ClientRegistry.Get(input); + var client = _oauth2ClientRegistry.Get(request); return await client.GetTokenByRefreshTokenAsync(refreshToken, CancellationToken.None); } - public string GetRefreshTokenServiceName(InputArguments input) + public string GetRefreshTokenServiceName(GitRequest request) { - var client = _oauth2ClientRegistry.Get(input); - return client.GetRefreshTokenServiceName(input); + var client = _oauth2ClientRegistry.Get(request); + return client.GetRefreshTokenServiceName(request); } protected internal virtual bool TryFindHelperCommand(out string command, out string args) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHelper.cs b/src/shared/Atlassian.Bitbucket/BitbucketHelper.cs index 3624627f0..d9cc0c9f9 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHelper.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHelper.cs @@ -14,9 +14,9 @@ public static string GetBaseUri(Uri remoteUri) return $"{remoteUri.Scheme}://{remoteUri.Host}:{remoteUri.Port}{path}"; } - public static bool IsBitbucketOrg(InputArguments input) + public static bool IsBitbucketOrg(GitRequest request) { - return IsBitbucketOrg(input.GetRemoteUri()); + return IsBitbucketOrg(request.GetRemoteUri()); } public static bool IsBitbucketOrg(Uri targetUri) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index 423300c4f..f4cb6d9f0 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -37,20 +37,20 @@ public BitbucketHostProvider(ICommandContext context, IBitbucketAuthentication b public IEnumerable SupportedAuthorityIds => BitbucketAuthentication.AuthorityIds; - public bool IsSupported(InputArguments input) + public bool IsSupported(GitRequest request) { - if (input is null) + if (request is null) { return false; } - if (input.WwwAuth.Any(x => x.Contains("realm=\"Atlassian Bitbucket\"", StringComparison.InvariantCultureIgnoreCase))) + if (request.WwwAuth.Any(x => x.Contains("realm=\"Atlassian Bitbucket\"", StringComparison.InvariantCultureIgnoreCase))) { return true; } - // Split port number and hostname from host input argument - if (!input.TryGetHostAndPort(out string hostName, out _)) + // Split port number and hostname from host request argument + if (!request.TryGetHostAndPort(out string hostName, out _)) { return false; } @@ -58,8 +58,8 @@ public bool IsSupported(InputArguments input) // We do not recommend unencrypted HTTP communications to Bitbucket, but it is possible. // Therefore, we report `true` here for HTTP so that we can show a helpful // error message for the user in `GetCredentialAsync`. - return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && + return (StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http") || + StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "https")) && hostName.EndsWith(CloudConstants.BitbucketBaseUrlHost, StringComparison.OrdinalIgnoreCase); } @@ -78,12 +78,12 @@ public bool IsSupported(HttpResponseMessage response) return supported; } - public async Task GetCredentialAsync(InputArguments input) + public async Task GetCredentialAsync(GitRequest request) { // We should not allow unencrypted communication and should inform the user if (!_context.Settings.AllowUnsafeRemotes && - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") && - BitbucketHelper.IsBitbucketOrg(input)) + StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http") && + BitbucketHelper.IsBitbucketOrg(request)) { throw new Trace2Exception(_context.Trace2, "Unencrypted HTTP is not recommended for Bitbucket.org. " + @@ -91,14 +91,14 @@ public async Task GetCredentialAsync(InputArguments input) $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); } - var authModes = await GetSupportedAuthenticationModesAsync(input); + var authModes = await GetSupportedAuthenticationModesAsync(request); - ICredential credential = await GetStoredCredentials(input, authModes) ?? - await GetRefreshedCredentials(input, authModes); - return new GetCredentialResult(credential); + ICredential credential = await GetStoredCredentials(request, authModes) ?? + await GetRefreshedCredentials(request, authModes); + return new GitResponse(credential); } - private async Task GetStoredCredentials(InputArguments input, AuthenticationModes authModes) + private async Task GetStoredCredentials(GitRequest request, AuthenticationModes authModes) { if (_context.Settings.TryGetSetting(BitbucketConstants.EnvironmentVariables.AlwaysRefreshCredentials, Constants.GitConfiguration.Credential.SectionName, BitbucketConstants.GitConfiguration.Credential.AlwaysRefreshCredentials, @@ -108,11 +108,11 @@ private async Task GetStoredCredentials(InputArguments input, Authe return null; } - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); string credentialService = GetServiceName(remoteUri); _context.Trace.WriteLine($"Look for existing credentials under {credentialService} ..."); - ICredential credentials = _context.CredentialStore.Get(credentialService, input.UserName); + ICredential credentials = _context.CredentialStore.Get(credentialService, request.UserName); if (credentials == null) { @@ -123,7 +123,7 @@ private async Task GetStoredCredentials(InputArguments input, Authe _context.Trace.WriteLineSecrets($"Found stored credentials: {credentials.Account}/{{0}}", new object[] { credentials.Password }); // Check credentials are still valid - if (!await ValidateCredentialsWork(input, credentials, authModes)) + if (!await ValidateCredentialsWork(request, credentials, authModes)) { return null; } @@ -131,17 +131,17 @@ private async Task GetStoredCredentials(InputArguments input, Authe return credentials; } - private async Task GetRefreshedCredentials(InputArguments input, AuthenticationModes authModes) + private async Task GetRefreshedCredentials(GitRequest request, AuthenticationModes authModes) { _context.Trace.WriteLine("Refresh credentials..."); // Check for presence of refresh_token entry in credential store - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); var refreshTokenService = GetRefreshTokenServiceName(remoteUri); _context.Trace.WriteLine("Checking for refresh token..."); ICredential refreshToken = SupportsOAuth(authModes) - ? _context.CredentialStore.Get(refreshTokenService, input.UserName) + ? _context.CredentialStore.Get(refreshTokenService, request.UserName) : null; if (refreshToken is null) @@ -152,7 +152,7 @@ private async Task GetRefreshedCredentials(InputArguments input, Au _context.Trace.WriteLine("Prompt for credentials..."); - var result = await _bitbucketAuth.GetCredentialsAsync(remoteUri, input.UserName, authModes); + var result = await _bitbucketAuth.GetCredentialsAsync(remoteUri, request.UserName, authModes); if (result is null || result.AuthenticationMode == AuthenticationModes.None) { var message = "User cancelled credential prompt"; @@ -183,7 +183,7 @@ private async Task GetRefreshedCredentials(InputArguments input, Au try { - return await GetOAuthCredentialsViaRefreshFlow(input, refreshToken); + return await GetOAuthCredentialsViaRefreshFlow(request, refreshToken); } catch (OAuth2Exception ex) { @@ -197,21 +197,21 @@ private async Task GetRefreshedCredentials(InputArguments input, Au } } - return await GetOAuthCredentialsViaInteractiveBrowserFlow(input); + return await GetOAuthCredentialsViaInteractiveBrowserFlow(request); } - private async Task GetOAuthCredentialsViaRefreshFlow(InputArguments input, ICredential refreshToken) + private async Task GetOAuthCredentialsViaRefreshFlow(GitRequest request, ICredential refreshToken) { - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); var refreshTokenService = GetRefreshTokenServiceName(remoteUri); _context.Trace.WriteLine("Refreshing OAuth credentials using refresh token..."); - OAuth2TokenResult refreshResult = await _bitbucketAuth.RefreshOAuthCredentialsAsync(input, refreshToken.Password); + OAuth2TokenResult refreshResult = await _bitbucketAuth.RefreshOAuthCredentialsAsync(request, refreshToken.Password); // Resolve the username _context.Trace.WriteLine("Resolving username for refreshed OAuth credential..."); - string refreshUserName = await ResolveOAuthUserNameAsync(input, refreshResult.AccessToken); + string refreshUserName = await ResolveOAuthUserNameAsync(request, refreshResult.AccessToken); _context.Trace.WriteLine($"Username for refreshed OAuth credential is '{refreshUserName}'"); // Store the refreshed RT @@ -222,9 +222,9 @@ private async Task GetOAuthCredentialsViaRefreshFlow(InputArguments return new GitCredential(refreshUserName, refreshResult.AccessToken); } - private async Task GetOAuthCredentialsViaInteractiveBrowserFlow(InputArguments input) + private async Task GetOAuthCredentialsViaInteractiveBrowserFlow(GitRequest request) { - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); var refreshTokenService = GetRefreshTokenServiceName(remoteUri); @@ -233,11 +233,11 @@ private async Task GetOAuthCredentialsViaInteractiveBrowserFlow(Inp // Start OAuth authentication flow _context.Trace.WriteLine("Starting OAuth authentication flow..."); - OAuth2TokenResult oauthResult = await _bitbucketAuth.CreateOAuthCredentialsAsync(input); + OAuth2TokenResult oauthResult = await _bitbucketAuth.CreateOAuthCredentialsAsync(request); // Resolve the username _context.Trace.WriteLine("Resolving username for OAuth credential..."); - string newUserName = await ResolveOAuthUserNameAsync(input, oauthResult.AccessToken); + string newUserName = await ResolveOAuthUserNameAsync(request, oauthResult.AccessToken); _context.Trace.WriteLine($"Username for OAuth credential is '{newUserName}'"); // Store the new RT @@ -259,7 +259,7 @@ private static bool SupportsBasicAuth(AuthenticationModes authModes) return (authModes & AuthenticationModes.Basic) != 0; } - public async Task GetSupportedAuthenticationModesAsync(InputArguments input) + public async Task GetSupportedAuthenticationModesAsync(GitRequest request) { // Check for an explicit override for supported authentication modes if (_context.Settings.TryGetSetting( @@ -279,19 +279,19 @@ public async Task GetSupportedAuthenticationModesAsync(Inpu } // It isn't possible to detect what Bitbucket.org is expecting so return the predefined answers. - if (BitbucketHelper.IsBitbucketOrg(input)) + if (BitbucketHelper.IsBitbucketOrg(request)) { // Bitbucket should use Basic, OAuth or manual PAT based authentication only - _context.Trace.WriteLine($"{input.GetRemoteUri()} is bitbucket.org - authentication schemes: '{CloudConstants.DotOrgAuthenticationModes}'"); + _context.Trace.WriteLine($"{request.GetRemoteUri()} is bitbucket.org - authentication schemes: '{CloudConstants.DotOrgAuthenticationModes}'"); return CloudConstants.DotOrgAuthenticationModes; } // For Bitbucket DC/Server the supported modes can be detected - _context.Trace.WriteLine($"{input.GetRemoteUri()} is Bitbucket DC - checking for supported authentication schemes..."); + _context.Trace.WriteLine($"{request.GetRemoteUri()} is Bitbucket DC - checking for supported authentication schemes..."); try { - var authenticationMethods = await _restApiRegistry.Get(input).GetAuthenticationMethodsAsync(); + var authenticationMethods = await _restApiRegistry.Get(request).GetAuthenticationMethodsAsync(); var modes = AuthenticationModes.None; @@ -300,7 +300,7 @@ public async Task GetSupportedAuthenticationModesAsync(Inpu modes |= AuthenticationModes.Basic; } - var isOauthInstalled = await _restApiRegistry.Get(input).IsOAuthInstalledAsync(); + var isOauthInstalled = await _restApiRegistry.Get(request).IsOAuthInstalledAsync(); if (isOauthInstalled) { modes |= AuthenticationModes.OAuth; @@ -312,7 +312,7 @@ public async Task GetSupportedAuthenticationModesAsync(Inpu catch (Exception ex) { var format = "Failed to query '{0}' for supported authentication schemes."; - var message = string.Format(format, input.GetRemoteUri()); + var message = string.Format(format, request.GetRemoteUri()); _context.Trace.WriteLine(message); _context.Trace.WriteException(ex); @@ -325,31 +325,31 @@ public async Task GetSupportedAuthenticationModesAsync(Inpu } } - public Task StoreCredentialAsync(InputArguments input) + public Task StoreCredentialAsync(GitRequest request) { // It doesn't matter if this is an OAuth access token, or the literal username & password // because we store them the same way, against the same credential key in the store. // The OAuth refresh token is already stored on the 'get' request. - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); string service = GetServiceName(remoteUri); _context.Trace.WriteLine("Storing credential..."); - _context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); + _context.CredentialStore.AddOrUpdate(service, request.UserName, request.Password); _context.Trace.WriteLine("Credential was successfully stored."); return Task.CompletedTask; } - public Task EraseCredentialAsync(InputArguments input) + public Task EraseCredentialAsync(GitRequest request) { // Erase the stored credential (which may be either the literal username & password, or // the OAuth access token). We don't need to erase the OAuth refresh token because on the // next 'get' request, if the RT is bad we will erase and reacquire a new one at that point. - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); string service = GetServiceName(remoteUri); _context.Trace.WriteLine("Erasing credential..."); - if (_context.CredentialStore.Remove(service, input.UserName)) + if (_context.CredentialStore.Remove(service, request.UserName)) { _context.Trace.WriteLine("Credential was successfully erased."); } @@ -365,9 +365,9 @@ public Task EraseCredentialAsync(InputArguments input) #region Private Methods - private async Task ResolveOAuthUserNameAsync(InputArguments input, string accessToken) + private async Task ResolveOAuthUserNameAsync(GitRequest request, string accessToken) { - RestApiResult result = await _restApiRegistry.Get(input).GetUserInformationAsync(null, accessToken, isBearerToken: true); + RestApiResult result = await _restApiRegistry.Get(request).GetUserInformationAsync(null, accessToken, isBearerToken: true); if (result.Succeeded) { return result.Response.UserName; @@ -377,9 +377,9 @@ private async Task ResolveOAuthUserNameAsync(InputArguments input, strin $"Failed to resolve username. HTTP: {result.StatusCode}"); } - private async Task ResolveBasicAuthUserNameAsync(InputArguments input, string username, string password) + private async Task ResolveBasicAuthUserNameAsync(GitRequest request, string username, string password) { - RestApiResult result = await _restApiRegistry.Get(input).GetUserInformationAsync(username, password, isBearerToken: false); + RestApiResult result = await _restApiRegistry.Get(request).GetUserInformationAsync(username, password, isBearerToken: false); if (result.Succeeded) { return result.Response.UserName; @@ -389,7 +389,7 @@ private async Task ResolveBasicAuthUserNameAsync(InputArguments input, s $"Failed to resolve username. HTTP: {result.StatusCode}"); } - private async Task ValidateCredentialsWork(InputArguments input, ICredential credentials, AuthenticationModes authModes) + private async Task ValidateCredentialsWork(GitRequest request, ICredential credentials, AuthenticationModes authModes) { if (_context.Settings.TryGetSetting( BitbucketConstants.EnvironmentVariables.ValidateStoredCredentials, @@ -408,7 +408,7 @@ private async Task ValidateCredentialsWork(InputArguments input, ICredenti // TODO: ideally we'd also check if the credentials have expired based on some local metadata // (once/if we get such metadata storage), and return false if they have. // This would be more efficient than having to make REST API calls to check. - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); _context.Trace.WriteLineSecrets($"Validate credentials ({credentials.Account}/{{0}}) are fresh for {remoteUri} ...", new object[] { credentials.Password }); // Bitbucket supports both OAuth + Basic Auth unless there is explicit GCM configuration. @@ -417,7 +417,7 @@ private async Task ValidateCredentialsWork(InputArguments input, ICredenti { try { - await ResolveOAuthUserNameAsync(input, credentials.Password); + await ResolveOAuthUserNameAsync(request, credentials.Password); _context.Trace.WriteLine("Validated existing credentials using OAuth"); return true; } @@ -434,7 +434,7 @@ private async Task ValidateCredentialsWork(InputArguments input, ICredenti { try { - await ResolveBasicAuthUserNameAsync(input, credentials.Account, credentials.Password); + await ResolveBasicAuthUserNameAsync(request, credentials.Account, credentials.Password); _context.Trace.WriteLine("Validated existing credentials using BasicAuth"); return true; } diff --git a/src/shared/Atlassian.Bitbucket/BitbucketOAuth2Client.cs b/src/shared/Atlassian.Bitbucket/BitbucketOAuth2Client.cs index 1ca23d0f5..0b3b91e3b 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketOAuth2Client.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketOAuth2Client.cs @@ -21,9 +21,9 @@ public BitbucketOAuth2Client(HttpClient httpClient, public abstract IEnumerable Scopes { get; } - public string GetRefreshTokenServiceName(InputArguments input) + public string GetRefreshTokenServiceName(GitRequest request) { - Uri baseUri = input.GetRemoteUri(includeUser: false); + Uri baseUri = request.GetRemoteUri(includeUser: false); // The refresh token key never includes the path component. // Instead we use the path component to specify this is the "refresh_token". diff --git a/src/shared/Atlassian.Bitbucket/BitbucketRestApiRegistry.cs b/src/shared/Atlassian.Bitbucket/BitbucketRestApiRegistry.cs index 950a46855..18715e9ff 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketRestApiRegistry.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketRestApiRegistry.cs @@ -14,9 +14,9 @@ public BitbucketRestApiRegistry(ICommandContext context) this.context = context; } - public IBitbucketRestApi Get(InputArguments input) + public IBitbucketRestApi Get(GitRequest request) { - if(!BitbucketHelper.IsBitbucketOrg(input)) + if(!BitbucketHelper.IsBitbucketOrg(request)) { return DataCenterApi; } diff --git a/src/shared/Atlassian.Bitbucket/IRegistry.cs b/src/shared/Atlassian.Bitbucket/IRegistry.cs index 5712e3770..a9c023028 100644 --- a/src/shared/Atlassian.Bitbucket/IRegistry.cs +++ b/src/shared/Atlassian.Bitbucket/IRegistry.cs @@ -5,6 +5,6 @@ namespace Atlassian.Bitbucket { public interface IRegistry : IDisposable { - T Get(InputArguments input); + T Get(GitRequest request); } } \ No newline at end of file diff --git a/src/shared/Atlassian.Bitbucket/OAuth2ClientRegistry.cs b/src/shared/Atlassian.Bitbucket/OAuth2ClientRegistry.cs index 800617ce8..cb7f1f9c4 100644 --- a/src/shared/Atlassian.Bitbucket/OAuth2ClientRegistry.cs +++ b/src/shared/Atlassian.Bitbucket/OAuth2ClientRegistry.cs @@ -17,9 +17,9 @@ public OAuth2ClientRegistry(ICommandContext context) _context = context; } - public BitbucketOAuth2Client Get(InputArguments input) + public BitbucketOAuth2Client Get(GitRequest request) { - if (!BitbucketHelper.IsBitbucketOrg(input)) + if (!BitbucketHelper.IsBitbucketOrg(request)) { return DataCenterClient; } diff --git a/src/shared/Core.Tests/Commands/CapabilityCommandTests.cs b/src/shared/Core.Tests/Commands/CapabilityCommandTests.cs new file mode 100644 index 000000000..9a90eb58b --- /dev/null +++ b/src/shared/Core.Tests/Commands/CapabilityCommandTests.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using GitCredentialManager.Commands; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace GitCredentialManager.Tests.Commands; + +public class CapabilityCommandTests +{ + [Fact] + public void CapabilityCommand_Execute_WritesVersionAndAdvertisedCapabilities() + { + var context = new TestCommandContext(); + + var command = new CapabilityCommand(context); + command.Execute(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + // First line MUST be `version ` per git-credential(1) CAPABILITY format; + // older Gits and helpers treat anything else as "no capabilities supported". + Assert.StartsWith("version 0\n", actualOutput); + + // GCM advertises the state capability. + Assert.Equal("version 0\ncapability state\n", actualOutput); + } + + [Fact] + public async Task CapabilityCommand_ExecuteAsync_DoesNotReadStandardInput() + { + // The capability action MUST NOT read stdin (it is not in the get/store/erase + // key=value protocol). If stdin contains anything the command should still work. + var context = new TestCommandContext + { + Streams = { In = "protocol=https\nhost=example.com\n\n" }, + }; + + var command = new CapabilityCommand(context); + await Task.Run(command.Execute); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.StartsWith("version 0\n", actualOutput); + } +} diff --git a/src/shared/Core.Tests/Commands/EraseCommandTests.cs b/src/shared/Core.Tests/Commands/EraseCommandTests.cs index 25f8c7b06..b944743c6 100644 --- a/src/shared/Core.Tests/Commands/EraseCommandTests.cs +++ b/src/shared/Core.Tests/Commands/EraseCommandTests.cs @@ -15,7 +15,7 @@ public async Task EraseCommand_ExecuteAsync_CallsHostProvider() const string testUserName = "john.doe"; const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] var stdin = $"protocol=http\nhost=example.com\nusername={testUserName}\npassword={testPassword}\n\n"; - var expectedInput = new InputArguments(new Dictionary + var expectedInput = new GitRequest(new Dictionary { ["protocol"] = "http", ["host"] = "example.com", @@ -24,7 +24,7 @@ public async Task EraseCommand_ExecuteAsync_CallsHostProvider() }); var providerMock = new Mock(); - providerMock.Setup(x => x.EraseCredentialAsync(It.IsAny())) + providerMock.Setup(x => x.EraseCredentialAsync(It.IsAny())) .Returns(Task.CompletedTask); var providerRegistry = new TestHostProviderRegistry {Provider = providerMock.Object}; var context = new TestCommandContext @@ -37,11 +37,11 @@ public async Task EraseCommand_ExecuteAsync_CallsHostProvider() await command.ExecuteAsync(); providerMock.Verify( - x => x.EraseCredentialAsync(It.Is(y => AreInputArgumentsEquivalent(expectedInput, y))), + x => x.EraseCredentialAsync(It.Is(y => AreRequestsEquivalent(expectedInput, y))), Times.Once); } - private static bool AreInputArgumentsEquivalent(InputArguments a, InputArguments b) + private static bool AreRequestsEquivalent(GitRequest a, GitRequest b) { return a.Protocol == b.Protocol && a.Host == b.Host && diff --git a/src/shared/Core.Tests/Commands/GetCommandTests.cs b/src/shared/Core.Tests/Commands/GetCommandTests.cs index 05bf32536..3878e5b40 100644 --- a/src/shared/Core.Tests/Commands/GetCommandTests.cs +++ b/src/shared/Core.Tests/Commands/GetCommandTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Text; @@ -27,8 +28,8 @@ public async Task GetCommand_ExecuteAsync_CallsHostProviderAndWritesCredential() }; var providerMock = new Mock(); - providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) - .ReturnsAsync(new GetCredentialResult(testCredential)); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(new GitResponse(testCredential)); var providerRegistry = new TestHostProviderRegistry {Provider = providerMock.Object}; var context = new TestCommandContext { @@ -41,10 +42,292 @@ public async Task GetCommand_ExecuteAsync_CallsHostProviderAndWritesCredential() IDictionary actualStdOutDict = ParseDictionary(context.Streams.Out); - providerMock.Verify(x => x.GetCredentialAsync(It.IsAny()), Times.Once); + providerMock.Verify(x => x.GetCredentialAsync(It.IsAny()), Times.Once); Assert.Equal(expectedStdOutDict, actualStdOutDict); } + [Fact] + public async Task GetCommand_ExecuteAsync_EmptyCredential_PreservesEmptyUsernameAndPassword() + { + // Regression: the generic provider returns empty username + password to + // signal Windows Integrated Authentication. Those empty values MUST be + // emitted (as `username=` / `password=`) for Git to use WIA. + ICredential emptyCredential = new GitCredential(string.Empty, string.Empty); + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(new GitResponse(emptyCredential)); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.Contains("username=\n", actualOutput); + Assert.Contains("password=\n", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_AdditionalProperties_AreEmitted() + { + // Regression: the generic provider emits `ntlm=allow` via + // GitResponse.AdditionalProperties. Those entries must round-trip + // to standard out so Git continues to honour them. + ICredential testCredential = new GitCredential(string.Empty, string.Empty); + var response = new GitResponse(testCredential); + response.AdditionalProperties["ntlm"] = "allow"; + + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + IDictionary actualStdOutDict = ParseDictionary(context.Streams.Out); + + Assert.True(actualStdOutDict.TryGetValue("ntlm", out string ntlmValue)); + Assert.Equal("allow", ntlmValue); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_NegotiatedCapability_EchoesIntersection() + { + // Git advertises authtype + state; GCM advertises state. The intersection + // (state) MUST be echoed back via `capability[]=state` per the protocol's + // capability negotiation rules. The unsupported authtype MUST NOT be + // echoed. + ICredential testCredential = new GitCredential("alice", "hunter2"); + var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\ncapability[]=state\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(new GitResponse(testCredential)); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.Contains("capability[]=state\n", actualOutput); + Assert.DoesNotContain("capability[]=authtype", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_NoCapabilityFromGit_EmitsNoCapabilityLines() + { + // Git declares no capabilities; even though GCM advertises state, the + // intersection is empty and no capability[] lines should be emitted. + ICredential testCredential = new GitCredential("alice", "hunter2"); + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(new GitResponse(testCredential)); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString(); + + Assert.DoesNotContain("capability", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_CancelledResponse_EmitsQuitAndNoCredential() + { + // A provider that declines to produce a credential (e.g. the user closed + // an auth prompt) returns GitResponse.Cancel(); the command MUST emit + // `quit=1` so Git aborts the credential acquisition pipeline rather than + // falling back to an interactive prompt that re-asks the user. No + // credential fields must be emitted. + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(GitResponse.Cancel()); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.Equal("quit=1\n\n", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_YieldedResponse_EmitsEmptyResponse() + { + // A provider that has nothing to contribute but does not want to stop + // the pipeline returns GitResponse.Yield(); the command MUST emit just + // the terminating blank line (no credential fields, no quit signal) so + // Git proceeds to the next helper or its interactive prompt. + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(GitResponse.Yield()); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.Equal("\n", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_StateNegotiated_EmitsStateLinesWithGcmPrefix() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential); + response.SetState("github.account", "alice"); + response.SetState("azure.tenant", "contoso"); + + var stdin = "protocol=https\nhost=example.com\ncapability[]=state\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.Contains("state[]=gcm.github.account=alice\n", actualOutput); + Assert.Contains("state[]=gcm.azure.tenant=contoso\n", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_StateNotNegotiated_DropsStateLines() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential); + response.SetState("github.account", "alice"); + + // Git did NOT advertise the state capability. + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString(); + + Assert.DoesNotContain("state[]", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_ContinueNegotiated_EmitsContinue1AlongsideCredential() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var stdin = "protocol=https\nhost=example.com\ncapability[]=state\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(GitResponse.Continue(testCredential)); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + Assert.Contains("username=alice\n", actualOutput); + Assert.Contains("password=hunter2\n", actualOutput); + Assert.Contains("continue=1\n", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_ContinueNotNegotiated_DropsContinueLine() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(GitResponse.Continue(testCredential)); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString(); + + // Credential still emitted; continue silently dropped. + Assert.Contains("username=alice", actualOutput); + Assert.DoesNotContain("continue=", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenScalarsThenContinueThenState() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Continue(testCredential); + response.SetState("k", "v"); + + var stdin = "protocol=https\nhost=example.com\ncapability[]=state\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); + + int posCapability = actualOutput.IndexOf("capability[]=state", StringComparison.Ordinal); + int posUsername = actualOutput.IndexOf("username=", StringComparison.Ordinal); + int posContinue = actualOutput.IndexOf("continue=1", StringComparison.Ordinal); + int posState = actualOutput.IndexOf("state[]=", StringComparison.Ordinal); + + Assert.True(posCapability >= 0 && posUsername > posCapability, + "capability[] must precede scalar fields"); + Assert.True(posContinue > posUsername, + "continue=1 must follow scalar fields"); + Assert.True(posState > posContinue, + "state[] must follow continue=1"); + } + #region Helpers private static IDictionary ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString()); diff --git a/src/shared/Core.Tests/Commands/GitCommandBaseTests.cs b/src/shared/Core.Tests/Commands/GitCommandBaseTests.cs index 090125ed6..4c1e7e9b4 100644 --- a/src/shared/Core.Tests/Commands/GitCommandBaseTests.cs +++ b/src/shared/Core.Tests/Commands/GitCommandBaseTests.cs @@ -17,11 +17,11 @@ public async Task GitCommandBase_ExecuteAsync_CallsExecuteInternalAsyncWithCorre var mockProvider = new Mock(); var mockHostRegistry = new Mock(); - mockHostRegistry.Setup(x => x.GetProviderAsync(It.IsAny())) + mockHostRegistry.Setup(x => x.GetProviderAsync(It.IsAny())) .ReturnsAsync(mockProvider.Object) .Verifiable(); - mockProvider.Setup(x => x.IsSupported(It.IsAny())) + mockProvider.Setup(x => x.IsSupported(It.IsAny())) .Returns(true); string standardIn = "protocol=test\nhost=example.com\npath=a/b/c\n\n"; @@ -34,12 +34,12 @@ public async Task GitCommandBase_ExecuteAsync_CallsExecuteInternalAsyncWithCorre GitCommandBase testCommand = new TestCommand(mockContext.Object, mockHostRegistry.Object) { - VerifyExecuteInternalAsync = (input, provider) => + VerifyExecuteInternalAsync = (request, provider) => { Assert.Same(mockProvider.Object, provider); - Assert.Equal("test", input.Protocol); - Assert.Equal("example.com", input.Host); - Assert.Equal("a/b/c", input.Path); + Assert.Equal("test", request.Protocol); + Assert.Equal("example.com", request.Host); + Assert.Equal("a/b/c", request.Path); } }; @@ -55,7 +55,7 @@ public async Task GitCommandBase_ExecuteAsync_ConfiguresSettingsRemoteUri() var mockSettings = new Mock(); var mockHostRegistry = new Mock(); - mockHostRegistry.Setup(x => x.GetProviderAsync(It.IsAny())) + mockHostRegistry.Setup(x => x.GetProviderAsync(It.IsAny())) .ReturnsAsync(mockProvider.Object); string standardIn = "protocol=test\nhost=example.com\npath=a/b/c\n\n"; @@ -84,13 +84,13 @@ public TestCommand(ICommandContext context, IHostProviderRegistry hostProviderRe { } - protected override Task ExecuteInternalAsync(InputArguments input, IHostProvider provider) + protected override Task ExecuteInternalAsync(GitRequest request, IHostProvider provider) { - VerifyExecuteInternalAsync?.Invoke(input, provider); + VerifyExecuteInternalAsync?.Invoke(request, provider); return Task.CompletedTask; } - public Action VerifyExecuteInternalAsync { get; set; } + public Action VerifyExecuteInternalAsync { get; set; } } } } diff --git a/src/shared/Core.Tests/Commands/StoreCommandTests.cs b/src/shared/Core.Tests/Commands/StoreCommandTests.cs index e7cd4acad..28a43bea1 100644 --- a/src/shared/Core.Tests/Commands/StoreCommandTests.cs +++ b/src/shared/Core.Tests/Commands/StoreCommandTests.cs @@ -14,7 +14,7 @@ public async Task StoreCommand_ExecuteAsync_CallsHostProvider() const string testUserName = "john.doe"; const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] var stdin = $"protocol=http\nhost=example.com\nusername={testUserName}\npassword={testPassword}\n\n"; - var expectedInput = new InputArguments(new Dictionary + var expectedInput = new GitRequest(new Dictionary { ["protocol"] = "http", ["host"] = "example.com", @@ -23,7 +23,7 @@ public async Task StoreCommand_ExecuteAsync_CallsHostProvider() }); var providerMock = new Mock(); - providerMock.Setup(x => x.StoreCredentialAsync(It.IsAny())) + providerMock.Setup(x => x.StoreCredentialAsync(It.IsAny())) .Returns(Task.CompletedTask); var providerRegistry = new TestHostProviderRegistry {Provider = providerMock.Object}; var context = new TestCommandContext @@ -36,11 +36,11 @@ public async Task StoreCommand_ExecuteAsync_CallsHostProvider() await command.ExecuteAsync(); providerMock.Verify( - x => x.StoreCredentialAsync(It.Is(y => AreInputArgumentsEquivalent(expectedInput, y))), + x => x.StoreCredentialAsync(It.Is(y => AreRequestsEquivalent(expectedInput, y))), Times.Once); } - bool AreInputArgumentsEquivalent(InputArguments a, InputArguments b) + bool AreRequestsEquivalent(GitRequest a, GitRequest b) { return a.Protocol == b.Protocol && a.Host == b.Host && diff --git a/src/shared/Core.Tests/GenericHostProviderTests.cs b/src/shared/Core.Tests/GenericHostProviderTests.cs index a4464555e..354e54916 100644 --- a/src/shared/Core.Tests/GenericHostProviderTests.cs +++ b/src/shared/Core.Tests/GenericHostProviderTests.cs @@ -35,7 +35,7 @@ public class GenericHostProviderTests [InlineData(null, false)] public void GenericHostProvider_IsSupported(string protocol, bool expected) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = "example.com", @@ -44,7 +44,7 @@ public void GenericHostProvider_IsSupported(string protocol, bool expected) var provider = new GenericHostProvider(new TestCommandContext()); - Assert.Equal(expected, provider.IsSupported(input)); + Assert.Equal(expected, provider.IsSupported(request)); } [Fact] @@ -52,7 +52,7 @@ public void GenericHostProvider_GetCredentialServiceUrl_ReturnsCorrectKey() { const string expectedService = "https://example.com/foo/bar"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -62,7 +62,7 @@ public void GenericHostProvider_GetCredentialServiceUrl_ReturnsCorrectKey() var provider = new GenericHostProvider(new TestCommandContext()); - string actualService = provider.GetServiceName(input); + string actualService = provider.GetServiceName(request); Assert.Equal(expectedService, actualService); } @@ -70,7 +70,7 @@ public void GenericHostProvider_GetCredentialServiceUrl_ReturnsCorrectKey() [Fact] public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_ReturnsBasicCredentialNoWiaCheck() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -93,7 +93,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -106,7 +106,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return [Fact] public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic_ReturnsBasicCredentialNoWiaCheck() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -129,7 +129,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -142,7 +142,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic [Fact] public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_ReturnsBasicCredentialNoWiaCheck() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "smtp", ["host"] = "example.com", @@ -162,7 +162,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -193,7 +193,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotSupported_Retu [WindowsFact] private static async Task GenericHostProvider_NtlmSuppressed_AllowOnce() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -218,7 +218,7 @@ private static async Task GenericHostProvider_NtlmSuppressed_AllowOnce() var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -236,7 +236,7 @@ private static async Task GenericHostProvider_NtlmSuppressed_AllowOnce() [WindowsFact] private static async Task GenericHostProvider_NtlmSuppressed_AllowAlways() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -261,7 +261,7 @@ private static async Task GenericHostProvider_NtlmSuppressed_AllowAlways() var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -281,7 +281,7 @@ private static async Task GenericHostProvider_NtlmSuppressed_AllowAlways() [WindowsFact] private static async Task GenericHostProvider_NtlmSuppressed_Disabled() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -306,7 +306,7 @@ private static async Task GenericHostProvider_NtlmSuppressed_Disabled() var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -323,7 +323,7 @@ private static async Task GenericHostProvider_NtlmSuppressed_Disabled() [Fact] public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAuthConfig_UsesOAuth() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "git.example.com", @@ -387,7 +387,7 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -409,7 +409,7 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(WindowsAuthenticationTypes supportedWiaTypes) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -426,7 +426,7 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(Windo var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -437,7 +437,7 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(Windo private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(WindowsAuthenticationTypes supportedWiaTypes) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -459,7 +459,7 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(Windo var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); - var result = await provider.GenerateCredentialAsync(input); + var result = await provider.GenerateCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); diff --git a/src/shared/Core.Tests/GenericOAuthConfigTests.cs b/src/shared/Core.Tests/GenericOAuthConfigTests.cs index b05ae2e8b..8b7898482 100644 --- a/src/shared/Core.Tests/GenericOAuthConfigTests.cs +++ b/src/shared/Core.Tests/GenericOAuthConfigTests.cs @@ -47,12 +47,12 @@ public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue() RemoteUri = remoteUri }; - var input = new InputArguments(new Dictionary { + var request = new GitRequest(new Dictionary { {"protocol", protocol}, {"host", host}, }); - bool result = GenericOAuthConfig.TryGet(trace, settings, input, out GenericOAuthConfig config); + bool result = GenericOAuthConfig.TryGet(trace, settings, request, out GenericOAuthConfig config); Assert.True(result); Assert.Equal(expectedClientId, config.ClientId); @@ -84,13 +84,13 @@ public void GenericOAuthConfig_TryGet_Gitea() RemoteUri = remoteUri }; - var input = new InputArguments(new Dictionary { + var request = new GitRequest(new Dictionary { {"protocol", protocol}, {"host", host}, {"wwwauth", "Basic realm=\"Gitea\""} }); - bool result = GenericOAuthConfig.TryGet(trace, settings, input, out GenericOAuthConfig config); + bool result = GenericOAuthConfig.TryGet(trace, settings, request, out GenericOAuthConfig config); Assert.True(result); Assert.Equal(expectedClientId, config.ClientId); diff --git a/src/shared/Core.Tests/GitCapabilitiesTests.cs b/src/shared/Core.Tests/GitCapabilitiesTests.cs new file mode 100644 index 000000000..88c7aa9b7 --- /dev/null +++ b/src/shared/Core.Tests/GitCapabilitiesTests.cs @@ -0,0 +1,62 @@ +using Xunit; + +namespace GitCredentialManager.Tests; + +public class GitCapabilitiesTests +{ + [Fact] + public void Advertised_IncludesState() + { + // GCM advertises support for the state capability so the negotiation + // handshake is functional end-to-end. Individual providers opt in to + // emitting state/continue piecemeal. + Assert.True(Constants.SupportedCapabilities.HasFlag(GitCapabilities.State)); + } + + [Fact] + public void ParseName_NullOrWhitespace_ReturnsNone() + { + Assert.Equal(GitCapabilities.None, GitCapabilitiesUtils.ParseName(null)); + Assert.Equal(GitCapabilities.None, GitCapabilitiesUtils.ParseName("")); + Assert.Equal(GitCapabilities.None, GitCapabilitiesUtils.ParseName(" ")); + } + + [Fact] + public void ParseName_UnknownName_ReturnsNone() + { + // Per git-credential(1): "Unrecognised attributes and capabilities are silently discarded." + Assert.Equal(GitCapabilities.None, GitCapabilitiesUtils.ParseName("totally-unknown")); + } + + [Fact] + public void ParseName_State_ReturnsStateFlag() + { + Assert.Equal(GitCapabilities.State, GitCapabilitiesUtils.ParseName("state")); + Assert.Equal(GitCapabilities.State, GitCapabilitiesUtils.ParseName("STATE")); + Assert.Equal(GitCapabilities.State, GitCapabilitiesUtils.ParseName("State")); + } + + [Fact] + public void ToProtocolName_None_Throws() + { + Assert.Throws(() => GitCapabilitiesUtils.ToProtocolName(GitCapabilities.None)); + } + + [Fact] + public void ToProtocolName_State_ReturnsStateString() + { + Assert.Equal("state", GitCapabilitiesUtils.ToProtocolName(GitCapabilities.State)); + } + + [Fact] + public void ToProtocolNames_None_ReturnsEmpty() + { + Assert.Empty(GitCapabilitiesUtils.ToProtocolNames(GitCapabilities.None)); + } + + [Fact] + public void ToProtocolNames_State_ReturnsStateOnly() + { + Assert.Equal(new[] { "state" }, GitCapabilitiesUtils.ToProtocolNames(GitCapabilities.State)); + } +} diff --git a/src/shared/Core.Tests/GitRequestTests.cs b/src/shared/Core.Tests/GitRequestTests.cs new file mode 100644 index 000000000..322293632 --- /dev/null +++ b/src/shared/Core.Tests/GitRequestTests.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace GitCredentialManager.Tests +{ + public class GitRequestTests + { + [Fact] + public void GitRequest_Ctor_Null_ThrowsArgNullException() + { + Assert.Throws(() => new GitRequest((IDictionary)null)); + Assert.Throws(() => new GitRequest((IDictionary>)null)); + } + + [Fact] + public void GitRequest_CommonArguments_ValuePresent_ReturnsValues() + { + var dict = new Dictionary> + { + ["protocol"] = new[] { "https" }, + ["host"] = new[] { "example.com" }, + ["path"] = new[] { "an/example/path" }, + ["username"] = new[] { "john.doe" }, + ["password"] = new[] { "password123" }, + ["wwwauth"] = new[] + { + "basic realm=\"example.com\"", + "bearer authorize_uri=https://id.example.com p=1 q=0" + } + }; + + var request = new GitRequest(dict); + + Assert.Equal("https", request.Protocol); + Assert.Equal("example.com", request.Host); + Assert.Equal("an/example/path", request.Path); + Assert.Equal("john.doe", request.UserName); + Assert.Equal("password123", request.Password); + Assert.Equal(new[] + { + "basic realm=\"example.com\"", + "bearer authorize_uri=https://id.example.com p=1 q=0" + }, + request.WwwAuth); + } + + [Fact] + public void GitRequest_CommonArguments_ValueMissing_ReturnsNullOrEmptyCollection() + { + var dict = new Dictionary(); + + var request = new GitRequest(dict); + + Assert.Null(request.Protocol); + Assert.Null(request.Host); + Assert.Null(request.Path); + Assert.Null(request.UserName); + Assert.Null(request.Password); + Assert.Empty(request.WwwAuth); + } + + [Fact] + public void GitRequest_OtherArguments() + { + var dict = new Dictionary> + { + ["foo"] = new[] { "bar" }, + ["multi"] = new[] { "val1", "val2", "val3" }, + }; + + var request = new GitRequest(dict); + + Assert.Equal("bar", request["foo"]); + Assert.Equal("bar", request.GetArgumentOrDefault("foo")); + Assert.Equal(new[] { "val1", "val2", "val3" }, request.GetMultiArgumentOrDefault("multi")); + } + + [Fact] + public void GitRequest_GetRemoteUri_NoAuthority_ReturnsNull() + { + var dict = new Dictionary(); + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(); + + Assert.Null(actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_Authority_ReturnsUriWithAuthority() + { + var expectedUri = new Uri("https://example.com/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_IncludeUser_Authority_ReturnsUriWithAuthorityAndUser() + { + var expectedUri = new Uri("https://john.doe@example.com/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + + // Username should appear in the returned URI; the password should not + ["username"] = "john.doe", + ["password"] = "password123" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_IncludeUserSpecialCharacters_Authority_ReturnsUriWithAuthorityAndUser() + { + var expectedUri = new Uri("https://john.doe%40domain.com@example.com/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + + // Username should appear in the returned URI; the password should not + ["username"] = "john.doe@domain.com", + ["password"] = "password123" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_IncludeUserNoUser_Authority_ReturnsUriWithAuthority() + { + var expectedUri = new Uri("https://example.com/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_AuthorityAndPort_ReturnsUriWithAuthorityAndPort() + { + var expectedUri = new Uri("https://example.com:456/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com:456" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_AuthorityPath_ReturnsUriWithAuthorityAndPath() + { + var expectedUri = new Uri("https://example.com/an/example/path"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + ["path"] = "an/example/path" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_AuthorityPathUserInfo_ReturnsUriWithAuthorityAndPath() + { + var expectedUri = new Uri("https://example.com/an/example/path"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + ["path"] = "an/example/path", + + // Username and password are not expected to appear in the returned URI + ["username"] = "john.doe", + ["password"] = "password123" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("foo?query=true")] + [InlineData("foo#fragment")] + [InlineData("foo?query=true#fragment")] + public void GitRequest_GetRemoteUri_PathQueryFragment_ReturnsCorrectUri(string path) + { + var expectedUri = new Uri($"https://example.com/{path}"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + ["path"] = path + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_GetRemoteUri_IncludeUser_AuthorityPathUserInfo_ReturnsUriWithAll() + { + var expectedUri = new Uri("https://john.doe@example.com/an/example/path"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + ["path"] = "an/example/path", + + // Username should appear in the returned URI; the password should not + ["username"] = "john.doe", + ["password"] = "password123" + }; + + var request = new GitRequest(dict); + + Uri actualUri = request.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void GitRequest_TryGetHostAndPort_NoPort_ReturnsHostName() + { + const string expectedHostName = "example.com"; + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com" + }; + + var request = new GitRequest(dict); + + bool result = request.TryGetHostAndPort(out string actualHostName, out int? actualPort); + + Assert.True(result); + Assert.NotNull(actualHostName); + Assert.Equal(expectedHostName, actualHostName); + Assert.Null(actualPort); + } + + [Fact] + public void GitRequest_TryGetHostAndPort_Port_ReturnsHostNameAndPort() + { + const string expectedHostName = "example.com"; + const int expectedPort = 456; + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com:456" + }; + + var request = new GitRequest(dict); + + bool result = request.TryGetHostAndPort(out string actualHostName, out int? actualPort); + + Assert.True(result); + Assert.NotNull(actualHostName); + Assert.Equal(expectedHostName, actualHostName); + Assert.NotNull(actualPort); + Assert.Equal(expectedPort, actualPort); + } + + [Fact] + public void GitRequest_TryGetHostAndPort_BadPort_ReturnsFalse() + { + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com:not-a-port" + }; + + var request = new GitRequest(dict); + + bool result = request.TryGetHostAndPort(out _, out int? actualPort); + + Assert.False(result); + Assert.Null(actualPort); + } + + [Fact] + public void GitRequest_TryGetHostAndPort_NoHostNoPort_ReturnsFalse() + { + var dict = new Dictionary + { + ["protocol"] = "https", + }; + + var request = new GitRequest(dict); + + bool result = request.TryGetHostAndPort(out _, out _); + + Assert.False(result); + } + + [Fact] + public void GitRequest_Capabilities_NoInput_ReturnsNone() + { + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + }; + + var request = new GitRequest(dict); + + Assert.Equal(GitCapabilities.None, request.Capabilities); + } + + [Fact] + public void GitRequest_Capabilities_UnknownNames_AreSilentlyDiscarded() + { + // Per git-credential(1): "Unrecognised attributes and capabilities are silently discarded." + var dict = new Dictionary> + { + ["protocol"] = new[] { "https" }, + ["host"] = new[] { "example.com" }, + ["capability"] = new[] { "this-cap-does-not-exist", "another-unknown" }, + }; + + var request = new GitRequest(dict); + + Assert.Equal(GitCapabilities.None, request.Capabilities); + } + + [Fact] + public void GitRequest_State_NoStateInput_IsEmpty() + { + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + }; + + var request = new GitRequest(dict); + + Assert.Empty(request.State); + } + + [Fact] + public void GitRequest_State_KeepsOnlyGcmPrefixedEntries_AndStripsPrefix() + { + var dict = new Dictionary> + { + ["protocol"] = new[] { "https" }, + ["host"] = new[] { "example.com" }, + ["state"] = new[] + { + "gcm.github.account=alice", + "other-helper.foo=bar", // not ours; ignored + "gcm.azure.tenant=contoso", + }, + }; + + var request = new GitRequest(dict); + + Assert.Equal(2, request.State.Count); + Assert.Equal("alice", request.State["github.account"]); + Assert.Equal("contoso", request.State["azure.tenant"]); + Assert.False(request.State.ContainsKey("other-helper.foo")); + } + + [Fact] + public void GitRequest_State_MalformedEntries_AreSilentlyDiscarded() + { + var dict = new Dictionary> + { + ["protocol"] = new[] { "https" }, + ["host"] = new[] { "example.com" }, + ["state"] = new[] + { + "gcm.valid=ok", + "gcm.no-equals", // malformed: no '=' + "=value-without-key", // malformed: empty key + "gcm.=empty-key-after-prefix", // empty key after prefix-strip + }, + }; + + var request = new GitRequest(dict); + + Assert.Single(request.State); + Assert.Equal("ok", request.State["valid"]); + } + + [Fact] + public void GitRequest_State_ValueMayContainEquals() + { + // Only the FIRST '=' separates key from value; the value may itself + // contain additional '=' characters. + var dict = new Dictionary> + { + ["protocol"] = new[] { "https" }, + ["host"] = new[] { "example.com" }, + ["state"] = new[] { "gcm.token=abc=def=ghi" }, + }; + + var request = new GitRequest(dict); + + Assert.Equal("abc=def=ghi", request.State["token"]); + } + } +} diff --git a/src/shared/Core.Tests/GitResponseTests.cs b/src/shared/Core.Tests/GitResponseTests.cs new file mode 100644 index 000000000..14f837895 --- /dev/null +++ b/src/shared/Core.Tests/GitResponseTests.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace GitCredentialManager.Tests; + +public class GitResponseTests +{ + [Fact] + public void GitResponse_Constructor_SetsCredential() + { + ICredential credential = new GitCredential("alice", "hunter2"); + + var response = new GitResponse(credential); + + Assert.Same(credential, response.Credential); + } + + [Fact] + public void GitResponse_AdditionalProperties_DefaultsToEmptyDictionary() + { + var response = new GitResponse(new GitCredential("alice", "hunter2")); + + Assert.NotNull(response.AdditionalProperties); + Assert.Empty(response.AdditionalProperties); + } + + [Fact] + public void GitResponse_AdditionalProperties_CaseInsensitive() + { + // Behavioural contract preserved from the legacy GetCredentialResult shape. + var response = new GitResponse(new GitCredential("alice", "hunter2")); + + response.AdditionalProperties["NTLM"] = "allow"; + + Assert.True(response.AdditionalProperties.TryGetValue("ntlm", out string value)); + Assert.Equal("allow", value); + } + + [Fact] + public void GitResponse_AdditionalProperties_CanBeReplaced() + { + // Existing in-tree callers (notably GenericHostProvider) assign via + // object-initializer syntax, so the setter must remain. + var response = new GitResponse(new GitCredential("alice", "hunter2")) + { + AdditionalProperties = new Dictionary + { + ["ntlm"] = "allow", + }, + }; + + Assert.Single(response.AdditionalProperties); + Assert.Equal("allow", response.AdditionalProperties["ntlm"]); + } + + [Fact] + public void GitResponse_Constructor_NullCredential_Throws() + { + // The non-cancelled ctor requires a credential. Use Cancel() for the no-credential case. + Assert.Throws(() => new GitResponse(null)); + } + + [Fact] + public void GitResponse_Ok_ReturnsSuccessfulResponseWithCredential() + { + ICredential credential = new GitCredential("alice", "hunter2"); + + var response = GitResponse.Ok(credential); + + Assert.Same(credential, response.Credential); + Assert.False(response.IsCancelled); + } + + [Fact] + public void GitResponse_Ok_NullCredential_Throws() + { + Assert.Throws(() => GitResponse.Ok(null)); + } + + [Fact] + public void GitResponse_Cancel_ReturnsCancellationResponseWithNoCredential() + { + var response = GitResponse.Cancel(); + + Assert.True(response.IsCancelled); + Assert.False(response.IsYielded); + Assert.Null(response.Credential); + } + + [Fact] + public void GitResponse_Yield_ReturnsYieldedResponseWithNoCredential() + { + var response = GitResponse.Yield(); + + Assert.True(response.IsYielded); + Assert.False(response.IsCancelled); + Assert.Null(response.Credential); + } + + [Fact] + public void GitResponse_Ok_IsNeitherCancelledNorYielded() + { + var response = GitResponse.Ok(new GitCredential("alice", "hunter2")); + + Assert.False(response.IsCancelled); + Assert.False(response.IsYielded); + Assert.False(response.IsContinue); + } + + #region Continue + + [Fact] + public void GitResponse_Continue_ReturnsResponseWithCredentialAndIsContinue() + { + ICredential credential = new GitCredential("alice", "hunter2"); + + var response = GitResponse.Continue(credential); + + Assert.Same(credential, response.Credential); + Assert.True(response.IsContinue); + Assert.False(response.IsCancelled); + Assert.False(response.IsYielded); + } + + [Fact] + public void GitResponse_Continue_NullCredential_Throws() + { + Assert.Throws(() => GitResponse.Continue(null)); + } + + #endregion + + #region State + + [Fact] + public void GitResponse_Ok_State_StartsEmptyAndIsReadOnlyView() + { + var response = GitResponse.Ok(new GitCredential("alice", "hunter2")); + + Assert.NotNull(response.State); + Assert.Empty(response.State); + // IReadOnlyDictionary exposes no mutation methods at all. + Assert.IsAssignableFrom>(response.State); + } + + [Fact] + public void GitResponse_SetState_OnOk_StoresEntry() + { + var response = GitResponse.Ok(new GitCredential("alice", "hunter2")); + + response.SetState("github.account", "alice"); + + Assert.Single(response.State); + Assert.Equal("alice", response.State["github.account"]); + } + + [Fact] + public void GitResponse_SetState_OnContinue_StoresEntry() + { + var response = GitResponse.Continue(new GitCredential("alice", "hunter2")); + + response.SetState("attempt", "1"); + + Assert.Equal("1", response.State["attempt"]); + } + + [Fact] + public void GitResponse_SetState_OnCancel_IsSilentNoOp() + { + // Setting state on a Cancel response makes no semantic sense (no + // credential is being returned, so there's nothing for state to + // accompany). The framework silently discards it so providers that + // build a response speculatively and then switch shape don't have to + // strip state. + var response = GitResponse.Cancel(); + + response.SetState("k", "v"); + + Assert.Empty(response.State); + } + + [Fact] + public void GitResponse_SetState_OnYield_IsSilentNoOp() + { + var response = GitResponse.Yield(); + + response.SetState("k", "v"); + + Assert.Empty(response.State); + } + + [Fact] + public void GitResponse_SetState_InvalidKey_AlwaysThrows() + { + // Validation is a wire-protocol concern: invalid keys/values would + // corrupt output regardless of which response shape we're attached to. + // Validation runs before the shape-based no-op so the bug surfaces + // even on Cancel/Yield. + var okResponse = GitResponse.Ok(new GitCredential("alice", "hunter2")); + var cancelResponse = GitResponse.Cancel(); + + Assert.Throws(() => okResponse.SetState("gcm.foo", "v")); + Assert.Throws(() => okResponse.SetState("k=k", "v")); + Assert.Throws(() => okResponse.SetState(null, "v")); + + Assert.Throws(() => cancelResponse.SetState("gcm.foo", "v")); + Assert.Throws(() => cancelResponse.SetState("k=k", "v")); + } + + [Fact] + public void GitResponse_SetState_InvalidValue_AlwaysThrows() + { + var okResponse = GitResponse.Ok(new GitCredential("alice", "hunter2")); + var yieldResponse = GitResponse.Yield(); + + Assert.Throws(() => okResponse.SetState("k", "has\nnewline")); + Assert.Throws(() => okResponse.SetState("k", null)); + + Assert.Throws(() => yieldResponse.SetState("k", "has\nnewline")); + } + + [Fact] + public void GitResponse_WithState_ReturnsSameInstanceForChaining() + { + var response = GitResponse.Ok(new GitCredential("alice", "hunter2")); + + GitResponse result = response.WithState("k", "v"); + + Assert.Same(response, result); + Assert.Equal("v", response.State["k"]); + } + + [Fact] + public void GitResponse_WithState_ChainsMultipleEntries() + { + var response = GitResponse.Continue(new GitCredential("alice", "hunter2")) + .WithState("github.account", "alice") + .WithState("attempt", "1"); + + Assert.Equal(2, response.State.Count); + Assert.Equal("alice", response.State["github.account"]); + Assert.Equal("1", response.State["attempt"]); + } + + [Fact] + public void GitResponse_WithState_OnCancel_StillReturnsResponseStateRemainsEmpty() + { + var response = GitResponse.Cancel().WithState("k", "v"); + + Assert.True(response.IsCancelled); + Assert.Empty(response.State); + } + + #endregion +} diff --git a/src/shared/Core.Tests/GitStateValidationTests.cs b/src/shared/Core.Tests/GitStateValidationTests.cs new file mode 100644 index 000000000..136a7eea4 --- /dev/null +++ b/src/shared/Core.Tests/GitStateValidationTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace GitCredentialManager.Tests; + +public class GitStateValidationTests +{ + #region IsValidKey + + [Theory] + [InlineData("github.account")] + [InlineData("plain")] + [InlineData("a.b.c.d")] + [InlineData("with-dashes")] + [InlineData("with_under")] + [InlineData("UPPER")] + public void IsValidKey_ValidKeys_ReturnsTrue(string key) + { + Assert.True(GitStateValidation.IsValidKey(key)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("has=equals")] + [InlineData("has\nnewline")] + [InlineData("has\0null")] + [InlineData("gcm.alreadyprefixed")] + public void IsValidKey_InvalidKeys_ReturnsFalse(string key) + { + Assert.False(GitStateValidation.IsValidKey(key)); + } + + #endregion + + #region IsValidValue + + [Theory] + [InlineData("")] + [InlineData("alice")] + [InlineData("value with spaces")] + [InlineData("value=with=equals")] // values may contain '='; only keys may not + public void IsValidValue_ValidValues_ReturnsTrue(string value) + { + Assert.True(GitStateValidation.IsValidValue(value)); + } + + [Theory] + [InlineData(null)] + [InlineData("has\nnewline")] + [InlineData("has\0null")] + public void IsValidValue_InvalidValues_ReturnsFalse(string value) + { + Assert.False(GitStateValidation.IsValidValue(value)); + } + + #endregion + + #region ValidateKey + + [Fact] + public void ValidateKey_NullOrEmpty_ThrowsArgumentException() + { + Assert.Throws(() => GitStateValidation.ValidateKey(null)); + Assert.Throws(() => GitStateValidation.ValidateKey("")); + } + + [Fact] + public void ValidateKey_ReservedPrefix_ThrowsArgumentException() + { + ArgumentException ex = Assert.Throws(() => GitStateValidation.ValidateKey("gcm.foo")); + Assert.Contains("gcm.", ex.Message); + } + + [Theory] + [InlineData("a=b")] + [InlineData("a\nb")] + [InlineData("a\0b")] + public void ValidateKey_BannedCharacters_ThrowsArgumentException(string key) + { + Assert.Throws(() => GitStateValidation.ValidateKey(key)); + } + + #endregion + + #region ValidateValue + + [Fact] + public void ValidateValue_Null_ThrowsArgumentNullException() + { + Assert.Throws(() => GitStateValidation.ValidateValue(null)); + } + + [Theory] + [InlineData("a\nb")] + [InlineData("a\0b")] + public void ValidateValue_BannedCharacters_ThrowsArgumentException(string value) + { + Assert.Throws(() => GitStateValidation.ValidateValue(value)); + } + + #endregion +} diff --git a/src/shared/Core.Tests/HostProviderRegistryTests.cs b/src/shared/Core.Tests/HostProviderRegistryTests.cs index 33725758d..0d43b1b52 100644 --- a/src/shared/Core.Tests/HostProviderRegistryTests.cs +++ b/src/shared/Core.Tests/HostProviderRegistryTests.cs @@ -39,9 +39,9 @@ public async Task HostProviderRegistry_GetProvider_NoProviders_ThrowException() { var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); - var input = new InputArguments(new Dictionary()); + var request = new GitRequest(new Dictionary()); - await Assert.ThrowsAsync(() => registry.GetProviderAsync(input)); + await Assert.ThrowsAsync(() => registry.GetProviderAsync(request)); } [Fact] @@ -50,20 +50,20 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_ReturnsSupp var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider2Mock.Object, result); } @@ -74,7 +74,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_StaticMatch var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); string providerId = "myProvider"; string configKey = string.Format(CultureInfo.InvariantCulture, @@ -84,11 +84,11 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_StaticMatch var providerMock = new Mock(); providerMock.Setup(x => x.Id).Returns(providerId); - providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); registry.Register(providerMock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(providerMock.Object, result); Assert.False(context.Git.Configuration.Global.TryGetValue(configKey, out _)); @@ -100,7 +100,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_DynamicMatc var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); string providerId = "myProvider"; string configKey = string.Format(CultureInfo.InvariantCulture, @@ -110,12 +110,12 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_DynamicMatc var providerMock = new Mock(); providerMock.Setup(x => x.Id).Returns(providerId); - providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); registry.Register(providerMock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(providerMock.Object, result); Assert.True(context.Git.Configuration.Global.TryGetValue(configKey, out IList config)); @@ -129,7 +129,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_DynamicMatc var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com/alice/repo.git/"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); string providerId = "myProvider"; string configKey = string.Format(CultureInfo.InvariantCulture, @@ -139,12 +139,12 @@ public async Task HostProviderRegistry_GetProvider_Auto_HasProviders_DynamicMatc var providerMock = new Mock(); providerMock.Setup(x => x.Id).Returns(providerId); - providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); registry.Register(providerMock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(providerMock.Object, result); Assert.True(context.Git.Configuration.Global.TryGetValue(configKey, out IList config)); @@ -158,20 +158,20 @@ public async Task HostProviderRegistry_GetProvider_Auto_MultipleValidProviders_R var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider1Mock.Object, result); } @@ -182,23 +182,23 @@ public async Task HostProviderRegistry_GetProvider_Auto_MultipleValidProvidersMu var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); var provider4Mock = new Mock(); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); - provider4Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider4Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); registry.Register(provider1Mock.Object, HostProviderPriority.Low); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.High); registry.Register(provider4Mock.Object, HostProviderPriority.Low); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider3Mock.Object, result); } @@ -211,23 +211,23 @@ public async Task HostProviderRegistry_GetProvider_ProviderSpecified_ReturnsProv Settings = {ProviderOverride = "provider3"} }; var registry = new HostProviderRegistry(context); - var input = new InputArguments(new Dictionary()); + var request = new GitRequest(new Dictionary()); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); provider1Mock.Setup(x => x.Id).Returns("provider1"); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); provider2Mock.Setup(x => x.Id).Returns("provider2"); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); provider3Mock.Setup(x => x.Id).Returns("provider3"); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider3Mock.Object, result); } @@ -241,23 +241,23 @@ public async Task HostProviderRegistry_GetProvider_AutoProviderSpecified_Returns }; var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); provider1Mock.Setup(x => x.Id).Returns("provider1"); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider2Mock.Setup(x => x.Id).Returns("provider2"); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); provider3Mock.Setup(x => x.Id).Returns("provider3"); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider2Mock.Object, result); } @@ -271,23 +271,23 @@ public async Task HostProviderRegistry_GetProvider_UnknownProviderSpecified_Retu }; var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); provider1Mock.Setup(x => x.Id).Returns("provider1"); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider2Mock.Setup(x => x.Id).Returns("provider2"); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); provider3Mock.Setup(x => x.Id).Returns("provider3"); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider2Mock.Object, result); } @@ -300,23 +300,23 @@ public async Task HostProviderRegistry_GetProvider_LegacyAuthoritySpecified_Retu Settings = {LegacyAuthorityOverride = "authorityB"} }; var registry = new HostProviderRegistry(context); - var input = new InputArguments(new Dictionary()); + var request = new GitRequest(new Dictionary()); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); provider1Mock.Setup(x => x.SupportedAuthorityIds).Returns(new[]{"authorityA"}); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider2Mock.Setup(x => x.SupportedAuthorityIds).Returns(new[]{"authorityB", "authorityC"}); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider3Mock.Setup(x => x.SupportedAuthorityIds).Returns(new[]{"authorityD"}); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider2Mock.Object, result); } @@ -330,23 +330,23 @@ public async Task HostProviderRegistry_GetProvider_AutoLegacyAuthoritySpecified_ }; var registry = new HostProviderRegistry(context); var remote = new Uri("https://example.com"); - InputArguments input = CreateInputArguments(remote); + GitRequest request = CreateRequest(remote); var provider1Mock = new Mock(); var provider2Mock = new Mock(); var provider3Mock = new Mock(); provider1Mock.Setup(x => x.SupportedAuthorityIds).Returns(new[]{"authorityA"}); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider2Mock.Setup(x => x.SupportedAuthorityIds).Returns(new[]{"authorityB", "authorityC"}); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); provider3Mock.Setup(x => x.SupportedAuthorityIds).Returns(new[]{"authorityD"}); - provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider3Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); registry.Register(provider3Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); Assert.Same(provider2Mock.Object, result); } @@ -357,14 +357,14 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_ReturnsSupp var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remoteUri = new Uri("https://provider2.onprem.example.com"); - InputArguments input = CreateInputArguments(remoteUri); + GitRequest request = CreateRequest(remoteUri); var provider1Mock = new Mock(); - provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider1Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); var provider2Mock = new Mock(); - provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); provider2Mock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized) @@ -380,7 +380,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_ReturnsSupp registry.Register(provider1Mock.Object, HostProviderPriority.Normal); registry.Register(provider2Mock.Object, HostProviderPriority.Normal); - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 1); Assert.Same(provider2Mock.Object, result); @@ -392,7 +392,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutZero var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remoteUri = new Uri("https://onprem.example.com"); - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = remoteUri.Scheme, @@ -401,7 +401,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutZero ); var providerMock = new Mock(); - providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized); @@ -414,7 +414,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutZero context.Settings.AutoDetectProviderTimeout = 0; - await Assert.ThrowsAnyAsync(() => registry.GetProviderAsync(input)); + await Assert.ThrowsAnyAsync(() => registry.GetProviderAsync(request)); httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 0); } @@ -425,7 +425,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutNega var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remoteUri = new Uri("https://onprem.example.com"); - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = remoteUri.Scheme, @@ -434,7 +434,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutNega ); var providerMock = new Mock(); - providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); providerMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized); @@ -447,7 +447,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutNega context.Settings.AutoDetectProviderTimeout = -1; - await Assert.ThrowsAnyAsync(() => registry.GetProviderAsync(input)); + await Assert.ThrowsAnyAsync(() => registry.GetProviderAsync(request)); httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 0); } @@ -458,7 +458,7 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_NoNetwork_R var context = new TestCommandContext(); var registry = new HostProviderRegistry(context); var remoteUri = new Uri("https://provider2.onprem.example.com"); - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = remoteUri.Scheme, @@ -467,12 +467,12 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_NoNetwork_R ); var highProviderMock = new Mock(); - highProviderMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); + highProviderMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); highProviderMock.Setup(x => x.IsSupported(It.IsAny())).Returns(false); registry.Register(highProviderMock.Object, HostProviderPriority.Normal); var lowProviderMock = new Mock(); - lowProviderMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); + lowProviderMock.Setup(x => x.IsSupported(It.IsAny())).Returns(true); registry.Register(lowProviderMock.Object, HostProviderPriority.Low); var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized) @@ -488,13 +488,13 @@ public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_NoNetwork_R httpHandler.Setup(HttpMethod.Head, remoteUri, responseMessage); context.HttpClientFactory.MessageHandler = httpHandler; - IHostProvider result = await registry.GetProviderAsync(input); + IHostProvider result = await registry.GetProviderAsync(request); httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 1); Assert.Same(lowProviderMock.Object, result); } - public static InputArguments CreateInputArguments(Uri uri) + public static GitRequest CreateRequest(Uri uri) { var dict = new Dictionary { @@ -507,7 +507,7 @@ public static InputArguments CreateInputArguments(Uri uri) dict["path"] = uri.AbsolutePath.TrimEnd('/'); } - return new InputArguments(dict); + return new GitRequest(dict); } } } diff --git a/src/shared/Core.Tests/HostProviderTests.cs b/src/shared/Core.Tests/HostProviderTests.cs index 8170655c6..5b2df8f04 100644 --- a/src/shared/Core.Tests/HostProviderTests.cs +++ b/src/shared/Core.Tests/HostProviderTests.cs @@ -15,7 +15,7 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string service = "https://example.com"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -35,7 +35,7 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti }, }; - var result = await ((IHostProvider) provider).GetCredentialAsync(input); + var result = await ((IHostProvider) provider).GetCredentialAsync(request); ICredential actualCredential = result.Credential; Assert.Equal(userName, actualCredential.Account); @@ -47,7 +47,7 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns { const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -67,7 +67,7 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns }, }; - var result = await ((IHostProvider) provider).GetCredentialAsync(input); + var result = await ((IHostProvider) provider).GetCredentialAsync(request); ICredential actualCredential = result.Credential; Assert.True(generateWasCalled); @@ -85,7 +85,7 @@ public async Task HostProvider_StoreCredentialAsync_EmptyCredential_DoesNotStore { const string emptyUserName = ""; const string emptyPassword = ""; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -96,7 +96,7 @@ public async Task HostProvider_StoreCredentialAsync_EmptyCredential_DoesNotStore var context = new TestCommandContext(); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).StoreCredentialAsync(input); + await ((IHostProvider) provider).StoreCredentialAsync(request); Assert.Equal(0, context.CredentialStore.Count); } @@ -107,7 +107,7 @@ public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_StoresCre const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string service = "https://example.com"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -118,7 +118,7 @@ public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_StoresCre var context = new TestCommandContext(); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).StoreCredentialAsync(input); + await ((IHostProvider) provider).StoreCredentialAsync(request); Assert.Equal(1, context.CredentialStore.Count); Assert.True(context.CredentialStore.TryGet(service, userName, out var storedCredential)); @@ -133,7 +133,7 @@ public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_ExistingC const string testPasswordOld = "letmein123-old"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string testPasswordNew = "letmein123-new"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string testService = "https://example.com"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -145,7 +145,7 @@ public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_ExistingC context.CredentialStore.Add(testService, testUserName, testPasswordOld); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).StoreCredentialAsync(input); + await ((IHostProvider) provider).StoreCredentialAsync(request); Assert.Equal(1, context.CredentialStore.Count); Assert.True(context.CredentialStore.TryGet(testService, testUserName, out var storedCredential)); @@ -164,7 +164,7 @@ public async Task HostProvider_EraseCredentialAsync_NoInputUser_CredentialExists const string userName1 = "john.doe"; const string userName2 = "alice"; const string userName3 = "bob"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -176,7 +176,7 @@ public async Task HostProvider_EraseCredentialAsync_NoInputUser_CredentialExists context.CredentialStore.Add(service, userName3, "here-forever"); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).EraseCredentialAsync(input); + await ((IHostProvider) provider).EraseCredentialAsync(request); Assert.Equal(2, context.CredentialStore.Count); } @@ -188,7 +188,7 @@ public async Task HostProvider_EraseCredentialAsync_InputUser_CredentialExists_U const string userName2 = "alice"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string service = "https://example.com"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -200,7 +200,7 @@ public async Task HostProvider_EraseCredentialAsync_InputUser_CredentialExists_U context.CredentialStore.Add(service, userName2, password); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).EraseCredentialAsync(input); + await ((IHostProvider) provider).EraseCredentialAsync(request); Assert.Equal(1, context.CredentialStore.Count); Assert.True(context.CredentialStore.Contains(service, userName2)); @@ -212,7 +212,7 @@ public async Task HostProvider_EraseCredentialAsync_InputUser_CredentialExists_U const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string service = "https://example.com"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -224,7 +224,7 @@ public async Task HostProvider_EraseCredentialAsync_InputUser_CredentialExists_U context.CredentialStore.Add(service, userName, password); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).EraseCredentialAsync(input); + await ((IHostProvider) provider).EraseCredentialAsync(request); Assert.Equal(0, context.CredentialStore.Count); Assert.False(context.CredentialStore.Contains(service, userName)); @@ -236,7 +236,7 @@ public async Task HostProvider_EraseCredentialAsync_DifferentHost_DoesNothing() const string service2 = "https://example2.com"; const string service3 = "https://example3.com"; const string userName = "john.doe"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example1.com", @@ -247,7 +247,7 @@ public async Task HostProvider_EraseCredentialAsync_DifferentHost_DoesNothing() context.CredentialStore.Add(service3, userName, "also-keep-me"); var provider = new TestHostProvider(context); - await ((IHostProvider) provider).EraseCredentialAsync(input); + await ((IHostProvider) provider).EraseCredentialAsync(request); Assert.Equal(2, context.CredentialStore.Count); Assert.True(context.CredentialStore.Contains(service2, userName)); diff --git a/src/shared/Core.Tests/InputArgumentsTests.cs b/src/shared/Core.Tests/InputArgumentsTests.cs deleted file mode 100644 index 44d8bae44..000000000 --- a/src/shared/Core.Tests/InputArgumentsTests.cs +++ /dev/null @@ -1,361 +0,0 @@ -using System; -using System.Collections.Generic; -using Xunit; - -namespace GitCredentialManager.Tests -{ - public class InputArgumentsTests - { - [Fact] - public void InputArguments_Ctor_Null_ThrowsArgNullException() - { - Assert.Throws(() => new InputArguments((IDictionary)null)); - Assert.Throws(() => new InputArguments((IDictionary>)null)); - } - - [Fact] - public void InputArguments_CommonArguments_ValuePresent_ReturnsValues() - { - var dict = new Dictionary> - { - ["protocol"] = new[] { "https" }, - ["host"] = new[] { "example.com" }, - ["path"] = new[] { "an/example/path" }, - ["username"] = new[] { "john.doe" }, - ["password"] = new[] { "password123" }, - ["wwwauth"] = new[] - { - "basic realm=\"example.com\"", - "bearer authorize_uri=https://id.example.com p=1 q=0" - } - }; - - 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); - Assert.Equal(new[] - { - "basic realm=\"example.com\"", - "bearer authorize_uri=https://id.example.com p=1 q=0" - }, - inputArgs.WwwAuth); - } - - [Fact] - public void InputArguments_CommonArguments_ValueMissing_ReturnsNullOrEmptyCollection() - { - 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); - Assert.Empty(inputArgs.WwwAuth); - } - - [Fact] - public void InputArguments_OtherArguments() - { - var dict = new Dictionary> - { - ["foo"] = new[] { "bar" }, - ["multi"] = new[] { "val1", "val2", "val3" }, - }; - - var inputArgs = new InputArguments(dict); - - Assert.Equal("bar", inputArgs["foo"]); - Assert.Equal("bar", inputArgs.GetArgumentOrDefault("foo")); - Assert.Equal(new[] { "val1", "val2", "val3" }, inputArgs.GetMultiArgumentOrDefault("multi")); - } - - [Fact] - public void InputArguments_GetRemoteUri_NoAuthority_ReturnsNull() - { - var dict = new Dictionary(); - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(); - - Assert.Null(actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_Authority_ReturnsUriWithAuthority() - { - var expectedUri = new Uri("https://example.com/"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_IncludeUser_Authority_ReturnsUriWithAuthorityAndUser() - { - var expectedUri = new Uri("https://john.doe@example.com/"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - - // Username should appear in the returned URI; the password should not - ["username"] = "john.doe", - ["password"] = "password123" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_IncludeUserSpecialCharacters_Authority_ReturnsUriWithAuthorityAndUser() - { - var expectedUri = new Uri("https://john.doe%40domain.com@example.com/"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - - // Username should appear in the returned URI; the password should not - ["username"] = "john.doe@domain.com", - ["password"] = "password123" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_IncludeUserNoUser_Authority_ReturnsUriWithAuthority() - { - var expectedUri = new Uri("https://example.com/"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_AuthorityAndPort_ReturnsUriWithAuthorityAndPort() - { - var expectedUri = new Uri("https://example.com:456/"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com:456" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_AuthorityPath_ReturnsUriWithAuthorityAndPath() - { - var expectedUri = new Uri("https://example.com/an/example/path"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - ["path"] = "an/example/path" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_AuthorityPathUserInfo_ReturnsUriWithAuthorityAndPath() - { - var expectedUri = new Uri("https://example.com/an/example/path"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - ["path"] = "an/example/path", - - // Username and password are not expected to appear in the returned URI - ["username"] = "john.doe", - ["password"] = "password123" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Theory] - [InlineData("foo?query=true")] - [InlineData("foo#fragment")] - [InlineData("foo?query=true#fragment")] - public void InputArguments_GetRemoteUri_PathQueryFragment_ReturnsCorrectUri(string path) - { - var expectedUri = new Uri($"https://example.com/{path}"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - ["path"] = path - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_GetRemoteUri_IncludeUser_AuthorityPathUserInfo_ReturnsUriWithAll() - { - var expectedUri = new Uri("https://john.doe@example.com/an/example/path"); - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com", - ["path"] = "an/example/path", - - // Username should appear in the returned URI; the password should not - ["username"] = "john.doe", - ["password"] = "password123" - }; - - var inputArgs = new InputArguments(dict); - - Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); - - Assert.NotNull(actualUri); - Assert.Equal(expectedUri, actualUri); - } - - [Fact] - public void InputArguments_TryGetHostAndPort_NoPort_ReturnsHostName() - { - const string expectedHostName = "example.com"; - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com" - }; - - var inputArgs = new InputArguments(dict); - - bool result = inputArgs.TryGetHostAndPort(out string actualHostName, out int? actualPort); - - Assert.True(result); - Assert.NotNull(actualHostName); - Assert.Equal(expectedHostName, actualHostName); - Assert.Null(actualPort); - } - - [Fact] - public void InputArguments_TryGetHostAndPort_Port_ReturnsHostNameAndPort() - { - const string expectedHostName = "example.com"; - const int expectedPort = 456; - - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com:456" - }; - - var inputArgs = new InputArguments(dict); - - bool result = inputArgs.TryGetHostAndPort(out string actualHostName, out int? actualPort); - - Assert.True(result); - Assert.NotNull(actualHostName); - Assert.Equal(expectedHostName, actualHostName); - Assert.NotNull(actualPort); - Assert.Equal(expectedPort, actualPort); - } - - [Fact] - public void InputArguments_TryGetHostAndPort_BadPort_ReturnsFalse() - { - var dict = new Dictionary - { - ["protocol"] = "https", - ["host"] = "example.com:not-a-port" - }; - - var inputArgs = new InputArguments(dict); - - bool result = inputArgs.TryGetHostAndPort(out _, out int? actualPort); - - Assert.False(result); - Assert.Null(actualPort); - } - - [Fact] - public void InputArguments_TryGetHostAndPort_NoHostNoPort_ReturnsFalse() - { - var dict = new Dictionary - { - ["protocol"] = "https", - }; - - var inputArgs = new InputArguments(dict); - - bool result = inputArgs.TryGetHostAndPort(out _, out _); - - Assert.False(result); - } - } -} diff --git a/src/shared/Core/Application.cs b/src/shared/Core/Application.cs index acdb6e27b..12747d955 100644 --- a/src/shared/Core/Application.cs +++ b/src/shared/Core/Application.cs @@ -85,6 +85,7 @@ void NoGuiOptionHandler(InvocationContext context) rootCommand.AddCommand(new GetCommand(Context, _providerRegistry)); rootCommand.AddCommand(new StoreCommand(Context, _providerRegistry)); rootCommand.AddCommand(new EraseCommand(Context, _providerRegistry)); + rootCommand.AddCommand(new CapabilityCommand(Context)); rootCommand.AddCommand(new ConfigureCommand(Context, _configurationService)); rootCommand.AddCommand(new UnconfigureCommand(Context, _configurationService)); rootCommand.AddCommand(diagnoseCommand); diff --git a/src/shared/Core/Commands/CapabilityCommand.cs b/src/shared/Core/Commands/CapabilityCommand.cs new file mode 100644 index 000000000..c80607ad3 --- /dev/null +++ b/src/shared/Core/Commands/CapabilityCommand.cs @@ -0,0 +1,62 @@ +using System.CommandLine; + +namespace GitCredentialManager.Commands; + +/// +/// Advertise the Git credential helper protocol capabilities that this +/// credential manager understands. +/// +/// +/// +/// Implements the capability action defined in the CAPABILITY INPUT/OUTPUT +/// FORMAT section of git-credential(1). +/// +/// +/// Unlike get / store / erase this action does not read +/// the standard credential key/value protocol from standard input; it writes +/// a fixed-format response to standard output and exits: +/// +/// +/// version 0 +/// capability <name> +/// ... +/// +/// +/// Git treats a non-zero exit, or a first line that does not begin with +/// version , as a signal that the helper supports no capabilities. +/// +/// +public class CapabilityCommand : Command +{ + /// + /// The Git credential helper capability protocol version this helper speaks. + /// + private const int ProtocolVersion = 0; + + private readonly ICommandContext _context; + + public CapabilityCommand(ICommandContext context) + : base("capability", "[Git] Advertise supported credential helper protocol capabilities") + { + EnsureArgument.NotNull(context, nameof(context)); + _context = context; + + IsHidden = true; + + this.SetHandler(Execute); + } + + internal void Execute() + { + _context.Trace.WriteLine("Start 'capability' command..."); + + _context.Streams.Out.WriteLine($"version {ProtocolVersion}"); + + foreach (string name in GitCapabilitiesUtils.ToProtocolNames(Constants.SupportedCapabilities)) + { + _context.Streams.Out.WriteLine($"capability {name}"); + } + + _context.Trace.WriteLine("End 'capability' command..."); + } +} diff --git a/src/shared/Core/Commands/EraseCommand.cs b/src/shared/Core/Commands/EraseCommand.cs index d0a27baee..ab850af5c 100644 --- a/src/shared/Core/Commands/EraseCommand.cs +++ b/src/shared/Core/Commands/EraseCommand.cs @@ -8,11 +8,14 @@ namespace GitCredentialManager.Commands public class EraseCommand : GitCommandBase { public EraseCommand(ICommandContext context, IHostProviderRegistry hostProviderRegistry) - : base(context, "erase", "[Git] Erase a stored credential", hostProviderRegistry) { } + : base(context, "erase", "[Git] Erase a stored credential", hostProviderRegistry) + { + IsHidden = true; + } - protected override Task ExecuteInternalAsync(InputArguments input, IHostProvider provider) + protected override Task ExecuteInternalAsync(GitRequest request, IHostProvider provider) { - return provider.EraseCredentialAsync(input); + return provider.EraseCredentialAsync(request); } } } diff --git a/src/shared/Core/Commands/GetCommand.cs b/src/shared/Core/Commands/GetCommand.cs index cce4a8dcf..8d9e91ee0 100644 --- a/src/shared/Core/Commands/GetCommand.cs +++ b/src/shared/Core/Commands/GetCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; namespace GitCredentialManager.Commands @@ -10,44 +9,137 @@ namespace GitCredentialManager.Commands public class GetCommand : GitCommandBase { public GetCommand(ICommandContext context, IHostProviderRegistry hostProviderRegistry) - : base(context, "get", "[Git] Return a stored credential", hostProviderRegistry) { } + : base(context, "get", "[Git] Return a stored credential", hostProviderRegistry) + { + IsHidden = true; + } - protected override async Task ExecuteInternalAsync(InputArguments input, IHostProvider provider) + protected override async Task ExecuteInternalAsync(GitRequest request, IHostProvider provider) { - GetCredentialResult result = await provider.GetCredentialAsync(input); - ICredential credential = result.Credential; + GitResponse response = await provider.GetCredentialAsync(request); + TextWriter stdout = Context.Streams.Out; - var output = new Dictionary(); + if (response.IsCancelled) + { + // Tell Git to stop the credential acquisition pipeline (no + // fallback interactive prompt) via the `quit` protocol + // attribute. This avoids re-prompting a user who has already + // explicitly cancelled in a GUI dialog. + Context.Trace.WriteLine("Provider cancelled the credential request; emitting quit=1."); + Context.Streams.Error.WriteLine("info: user cancelled the credential request."); + stdout.WriteLine("quit=1"); + stdout.WriteLine(); + return; + } - // Echo protocol, host, and path back at Git - if (input.Protocol != null) + if (response.IsYielded) { - output["protocol"] = input.Protocol; + // Empty response (just the terminating blank line) lets Git + // proceed to the next helper or its interactive prompt. + Context.Trace.WriteLine("Provider yielded; emitting empty response."); + stdout.WriteLine(); + return; } - if (input.Host != null) + + ICredential credential = response.Credential; + + // Negotiate capabilities by intersecting what Git advertised with what GCM supports. + // Capability-gated output fields may only be emitted for capabilities in this set. + GitCapabilities negotiated = request.Capabilities & Constants.SupportedCapabilities; + bool stateCapNegotiated = (negotiated & GitCapabilities.State) != 0; + + Context.Trace.WriteLine($"Git capability: {request.Capabilities}"); + Context.Trace.WriteLine($"GCM capabilities: {Constants.SupportedCapabilities}"); + Context.Trace.WriteLine($"Negotiated capabilities: {negotiated}"); + + Context.Trace.WriteLine("Writing credentials to output:"); + + // + // Capabilities + // + foreach (string name in GitCapabilitiesUtils.ToProtocolNames(negotiated)) { - output["host"] = input.Host; + stdout.WriteLine($"capability[]={name}"); + Context.Trace.WriteLine($"\tcapability[]={name}"); } - if (input.Path != null) + + // + // Common arguments + // + if (request.Protocol != null) { - output["path"] = input.Path; + stdout.WriteLine($"protocol={request.Protocol}"); + Context.Trace.WriteLine($"\tprotocol={request.Protocol}"); + } + if (request.Host != null) + { + stdout.WriteLine($"host={request.Host}"); + Context.Trace.WriteLine($"\thost={request.Host}"); + } + if (request.Path != null) + { + stdout.WriteLine($"path={request.Path}"); + Context.Trace.WriteLine($"\tpath={request.Path}"); } - // Return the credential to Git - output["username"] = credential.Account; - output["password"] = credential.Password; + // + // Credential + // + stdout.WriteLine($"username={credential.Account}"); + Context.Trace.WriteLine($"\tusername={credential.Account}"); + stdout.WriteLine($"password={credential.Password}"); + Context.Trace.WriteLineSecrets("\tpassword={0}", new object[] { credential.Password }); - // Write any additional output from the provider - foreach (var kvp in result.AdditionalProperties) + // + // Custom additional properties + // + foreach (var kvp in response.AdditionalProperties) { - output[kvp.Key] = kvp.Value; + stdout.WriteLine($"{kvp.Key}={kvp.Value}"); + Context.Trace.WriteLine($"\t{kvp.Key}={kvp.Value}"); } - Context.Trace.WriteLine("Writing credentials to output:"); - Context.Trace.WriteDictionarySecrets(output, new []{ "password" }, StringComparer.OrdinalIgnoreCase); + if (response.IsContinue) + { + if (stateCapNegotiated) + { + stdout.WriteLine($"{Constants.CredentialProtocol.ContinueKey}=1"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.ContinueKey}=1"); + } + else + { + // Dropping continue=1 changes the auth semantics: Git will treat this + // credential as final and likely fail on the next 401! + Context.Trace.WriteLine( + "WARNING: Provider set continue=1 but the 'state' capability was not " + + "negotiated with Git. Dropping continue=1; multistage authentication " + + "will not work and the credential will likely fail on the next 401."); + } + } + + if (response.State.Count > 0) + { + if (stateCapNegotiated) + { + foreach (var kvp in response.State) + { + string line = + $"{Constants.CredentialProtocol.StateKey}[]=" + + $"{Constants.CredentialProtocol.GcmStatePrefix}{kvp.Key}={kvp.Value}"; + stdout.WriteLine(line); + Context.Trace.WriteLine($"\t{line}"); + } + } + else + { + Context.Trace.WriteLine( + $"Provider set {response.State.Count} state entries but the 'state' " + + "capability was not negotiated with Git; dropping."); + } + } - // Write the values to standard out - Context.Streams.Out.WriteDictionary(output); + // Terminating blank line per the credential protocol. + stdout.WriteLine(); } } } diff --git a/src/shared/Core/Commands/GitCommandBase.cs b/src/shared/Core/Commands/GitCommandBase.cs index b277d1a75..f9b56bb8e 100644 --- a/src/shared/Core/Commands/GitCommandBase.cs +++ b/src/shared/Core/Commands/GitCommandBase.cs @@ -7,7 +7,7 @@ namespace GitCredentialManager.Commands { /// /// Represents a command which selects a from a - /// based on the from standard input, and interacts with a . + /// based on the from standard input, and interacts with a . /// public abstract class GitCommandBase : Command { @@ -34,56 +34,56 @@ internal async Task ExecuteAsync() // Parse standard input arguments // git-credential treats the keys as case-sensitive; so should we. IDictionary> inputDict = await Context.Streams.In.ReadMultiDictionaryAsync(StringComparer.Ordinal); - var input = new InputArguments(inputDict); + var request = new GitRequest(inputDict); // Validate minimum arguments are present - EnsureMinimumInputArguments(input); + EnsureMinimumRequest(request); // Set the remote URI to scope settings to throughout the process from now on - Context.Settings.RemoteUri = input.GetRemoteUri(); + Context.Settings.RemoteUri = request.GetRemoteUri(); // Determine the host provider - Context.Trace.WriteLine("Detecting host provider for input:"); + Context.Trace.WriteLine("Detecting host provider for request:"); Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password" }, StringComparer.OrdinalIgnoreCase); - IHostProvider provider = await _hostProviderRegistry.GetProviderAsync(input); + IHostProvider provider = await _hostProviderRegistry.GetProviderAsync(request); Context.Trace.WriteLine($"Host provider '{provider.Name}' was selected."); - await ExecuteInternalAsync(input, provider); + await ExecuteInternalAsync(request, provider); Context.Trace.WriteLine($"End '{Name}' command..."); } - protected virtual void EnsureMinimumInputArguments(InputArguments input) + protected virtual void EnsureMinimumRequest(GitRequest request) { - if (input.Protocol is null) + if (request.Protocol is null) { - throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'protocol' input argument"); + throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'protocol' request argument"); } - if (string.IsNullOrWhiteSpace(input.Protocol)) + if (string.IsNullOrWhiteSpace(request.Protocol)) { throw new Trace2InvalidOperationException(Context.Trace2, - "Invalid 'protocol' input argument (cannot be empty)"); + "Invalid 'protocol' request argument (cannot be empty)"); } - if (input.Host is null) + if (request.Host is null) { - throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'host' input argument"); + throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'host' request argument"); } - if (string.IsNullOrWhiteSpace(input.Host)) + if (string.IsNullOrWhiteSpace(request.Host)) { throw new Trace2InvalidOperationException(Context.Trace2, - "Invalid 'host' input argument (cannot be empty)"); + "Invalid 'host' request argument (cannot be empty)"); } } /// - /// Execute the command using the given and . + /// Execute the command using the given and . /// - /// Input arguments of the current Git credential query. - /// Host provider for the current . + /// Input arguments of the current Git credential query. + /// Host provider for the current . /// Awaitable task for the command execution. - protected abstract Task ExecuteInternalAsync(InputArguments input, IHostProvider provider); + protected abstract Task ExecuteInternalAsync(GitRequest request, IHostProvider provider); } } diff --git a/src/shared/Core/Commands/StoreCommand.cs b/src/shared/Core/Commands/StoreCommand.cs index 8085e87ed..a4d960d9b 100644 --- a/src/shared/Core/Commands/StoreCommand.cs +++ b/src/shared/Core/Commands/StoreCommand.cs @@ -9,26 +9,29 @@ namespace GitCredentialManager.Commands public class StoreCommand : GitCommandBase { public StoreCommand(ICommandContext context, IHostProviderRegistry hostProviderRegistry) - : base(context, "store", "[Git] Store a credential", hostProviderRegistry) { } + : base(context, "store", "[Git] Store a credential", hostProviderRegistry) + { + IsHidden = true; + } - protected override Task ExecuteInternalAsync(InputArguments input, IHostProvider provider) + protected override Task ExecuteInternalAsync(GitRequest request, IHostProvider provider) { - return provider.StoreCredentialAsync(input); + return provider.StoreCredentialAsync(request); } - protected override void EnsureMinimumInputArguments(InputArguments input) + protected override void EnsureMinimumRequest(GitRequest request) { - base.EnsureMinimumInputArguments(input); + base.EnsureMinimumRequest(request); // An empty string username/password are valid inputs, so only check for `null` (not provided) - if (input.UserName is null) + if (request.UserName is null) { - throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'username' input argument"); + throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'username' request argument"); } - if (input.Password is null) + if (request.Password is null) { - throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'password' input argument"); + throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'password' request argument"); } } } diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 6fecc2b38..12d66ef57 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -33,11 +33,38 @@ public static class Constants public const string DefaultWorkloadFederationAudience = "api://AzureADTokenExchange"; + /// + /// The set of Git credential protocol capabilities supported by Git Credential Manager. + /// + public const GitCapabilities SupportedCapabilities = GitCapabilities.State; + public static class CredentialProtocol { public const string NtlmKey = "ntlm"; public const string NtlmSuppressed = "suppressed"; public const string NtlmAllow = "allow"; + + /// + /// The Git credential protocol attribute name carrying opaque per-helper state + /// (multi-valued, gated by the state capability). + /// + public const string StateKey = "state"; + + /// + /// The Git credential protocol attribute name signalling that the credential + /// helper expects a further round of authentication (boolean, gated by the + /// state capability). + /// + public const string ContinueKey = "continue"; + + /// + /// Prefix that Git Credential Manager reserves on every state[] entry + /// so its state can be distinguished from other helpers' state per + /// git-credential(1): + /// "The value should include a prefix unique to the credential helper and + /// should ignore values that don't match its prefix." + /// + public const string GcmStatePrefix = "gcm."; } public static class CredentialStoreNames diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index a66729a8a..a6470ff1e 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -46,10 +46,10 @@ public GenericHostProvider(ICommandContext context, WindowsIntegratedAuthentication.AuthorityIds ); - public bool IsSupported(InputArguments input) + public bool IsSupported(GitRequest request) { // The generic provider should support all possible protocols (HTTP, HTTPS, SMTP, IMAP, etc) - return input != null && !string.IsNullOrWhiteSpace(input.Protocol); + return request != null && !string.IsNullOrWhiteSpace(request.Protocol); } public bool IsSupported(HttpResponseMessage response) @@ -57,65 +57,65 @@ public bool IsSupported(HttpResponseMessage response) return false; } - public string GetServiceName(InputArguments input) + public string GetServiceName(GitRequest request) { // By default we assume the service name will be the absolute URI based on the - // input arguments from Git, without any userinfo part. - return input.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/'); + // request arguments from Git, without any userinfo part. + return request.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/'); } - public async Task GetCredentialAsync(InputArguments input) + public async Task GetCredentialAsync(GitRequest request) { // Try and locate an existing credential in the OS credential store - string service = GetServiceName(input); - _context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={input.UserName}..."); + string service = GetServiceName(request); + _context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={request.UserName}..."); - ICredential credential = _context.CredentialStore.Get(service, input.UserName); + ICredential credential = _context.CredentialStore.Get(service, request.UserName); if (credential == null) { _context.Trace.WriteLine("No existing credentials found."); // No existing credential was found, create a new one _context.Trace.WriteLine("Creating new credential..."); - return await GenerateCredentialAsync(input); + return await GenerateCredentialAsync(request); } else { _context.Trace.WriteLine("Existing credential found."); } - return new GetCredentialResult(credential); + return new GitResponse(credential); } - public Task StoreCredentialAsync(InputArguments input) + public Task StoreCredentialAsync(GitRequest request) { - string service = GetServiceName(input); + string service = GetServiceName(request); // WIA-authentication is signaled to Git as an empty username/password pair // and we will get called to 'store' these WIA credentials. // We avoid storing empty credentials. - if (string.IsNullOrWhiteSpace(input.UserName) && string.IsNullOrWhiteSpace(input.Password)) + if (string.IsNullOrWhiteSpace(request.UserName) && string.IsNullOrWhiteSpace(request.Password)) { _context.Trace.WriteLine("Not storing empty credential."); } else { // Add or update the credential in the store. - _context.Trace.WriteLine($"Storing credential with service={service} account={input.UserName}..."); - _context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); + _context.Trace.WriteLine($"Storing credential with service={service} account={request.UserName}..."); + _context.CredentialStore.AddOrUpdate(service, request.UserName, request.Password); _context.Trace.WriteLine("Credential was successfully stored."); } return Task.CompletedTask; } - public Task EraseCredentialAsync(InputArguments input) + public Task EraseCredentialAsync(GitRequest request) { - string service = GetServiceName(input); + string service = GetServiceName(request); // Try to locate an existing credential - _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={input.UserName}..."); - if (_context.CredentialStore.Remove(service, input.UserName)) + _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={request.UserName}..."); + if (_context.CredentialStore.Remove(service, request.UserName)) { _context.Trace.WriteLine("Credential was successfully erased."); } @@ -127,7 +127,7 @@ public Task EraseCredentialAsync(InputArguments input) return Task.CompletedTask; } - public async Task GenerateCredentialAsync(InputArguments input) + public async Task GenerateCredentialAsync(GitRequest request) { ThrowIfDisposed(); @@ -135,14 +135,14 @@ public async Task GenerateCredentialAsync(InputArguments in // and, historically, we never blocked HTTP remotes in this provider. // The user can always set the 'GCM_ALLOW_UNSAFE' setting to silence the warning. if (!_context.Settings.AllowUnsafeRemotes && - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http")) { _context.Streams.Error.WriteLine( "warning: use of unencrypted HTTP remote URLs is not recommended; " + $"see {Constants.HelpUrls.GcmUnsafeRemotes} for more information."); } - Uri uri = input.GetRemoteUri(); + Uri uri = request.GetRemoteUri(); // Determine the if the host supports Windows Integration Authentication (WIA) or OAuth if (!StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "http") && @@ -151,7 +151,7 @@ public async Task GenerateCredentialAsync(InputArguments in // Cannot check WIA or OAuth support for non-HTTP based protocols } // Check for an OAuth configuration for this remote - else if (GenericOAuthConfig.TryGet(_context.Trace, _context.Settings, input, out GenericOAuthConfig oauthConfig)) + else if (GenericOAuthConfig.TryGet(_context.Trace, _context.Settings, request, out GenericOAuthConfig oauthConfig)) { _context.Trace.WriteLine($"Found generic OAuth configuration for '{uri}':"); _context.Trace.WriteLine($"\tAuthzEndpoint = {oauthConfig.Endpoints.AuthorizationEndpoint}"); @@ -164,8 +164,8 @@ public async Task GenerateCredentialAsync(InputArguments in _context.Trace.WriteLine($"\tUseAuthHeader = {oauthConfig.UseAuthHeader}"); _context.Trace.WriteLine($"\tDefaultUserName = {oauthConfig.DefaultUserName}"); - return new GetCredentialResult( - await GetOAuthAccessToken(uri, input.UserName, oauthConfig, _context.Trace2) + return new GitResponse( + await GetOAuthAccessToken(uri, request.UserName, oauthConfig, _context.Trace2) ); } // Try detecting WIA for this remote, if permitted @@ -188,7 +188,7 @@ await GetOAuthAccessToken(uri, input.UserName, oauthConfig, _context.Trace2) var additionalProps = new Dictionary(StringComparer.OrdinalIgnoreCase); // Has Git suppressed its own built-in NTLM authentication support? - if (input.TryGetArgument(Constants.CredentialProtocol.NtlmKey, out string ntlmArg) && + if (request.TryGetArgument(Constants.CredentialProtocol.NtlmKey, out string ntlmArg) && StringComparer.OrdinalIgnoreCase.Equals(Constants.CredentialProtocol.NtlmSuppressed, ntlmArg)) { _context.Trace.WriteLine("NTLM support has been suppressed by Git - showing warning."); @@ -213,7 +213,7 @@ await GetOAuthAccessToken(uri, input.UserName, oauthConfig, _context.Trace2) default: _context.Trace.WriteLine("User declined to enable NTLM support. Showing basic auth prompt."); - return new GetCredentialResult( + return new GitResponse( await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, null) ); } @@ -222,7 +222,7 @@ await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, null) // WIA is signaled to Git using an empty username/password _context.Trace.WriteLine("Returning empty username/password to trigger current user auth with WIA."); ICredential creds = new GitCredential(string.Empty, string.Empty); - return new GetCredentialResult(creds) + return new GitResponse(creds) { AdditionalProperties = additionalProps }; @@ -241,8 +241,8 @@ await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, null) // Use basic authentication _context.Trace.WriteLine("Prompting for basic credentials..."); - return new GetCredentialResult( - await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName) + return new GitResponse( + await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, request.UserName) ); } diff --git a/src/shared/Core/GenericOAuthConfig.cs b/src/shared/Core/GenericOAuthConfig.cs index 522d89fec..ff95a4a0d 100644 --- a/src/shared/Core/GenericOAuthConfig.cs +++ b/src/shared/Core/GenericOAuthConfig.cs @@ -7,14 +7,14 @@ namespace GitCredentialManager { public class GenericOAuthConfig { - public static bool TryGet(ITrace trace, ISettings settings, InputArguments input, out GenericOAuthConfig config) + public static bool TryGet(ITrace trace, ISettings settings, GitRequest request, out GenericOAuthConfig config) { config = new GenericOAuthConfig(); Uri authzEndpointUri = null; Uri tokenEndpointUri = null; - var remoteUri = input.GetRemoteUri(); + var remoteUri = request.GetRemoteUri(); - if (input.WwwAuth.Any(x => x.Contains("Basic realm=\"Gitea\"", StringComparison.OrdinalIgnoreCase))) + if (request.WwwAuth.Any(x => x.Contains("Basic realm=\"Gitea\"", StringComparison.OrdinalIgnoreCase))) { trace.WriteLine($"Using universal Gitea OAuth configuration"); // https://docs.gitea.com/next/development/oauth2-provider?_highlight=oauth#pre-configured-applications diff --git a/src/shared/Core/GitCapabilities.cs b/src/shared/Core/GitCapabilities.cs new file mode 100644 index 000000000..c12770030 --- /dev/null +++ b/src/shared/Core/GitCapabilities.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; + +namespace GitCredentialManager; + +/// +/// Capabilities of the Git credential helper protocol introduced in Git 2.46. +/// +/// +/// +/// A capability is negotiated between Git and the credential helper: each side +/// emits capability[] entries describing what it understands, and only +/// the intersection may be safely exercised in the rest of the exchange. +/// +/// +/// Unrecognized capability names are silently discarded per +/// git-credential(1). +/// +/// +/// +/// determines which capabilities GCM itself advertises back to Git. +/// +/// +[Flags] +public enum GitCapabilities +{ + /// + /// No capabilities are supported. + /// + None = 0, + + /// + /// The state[] and continue attributes are understood. + /// + /// + /// Provides for opaque per-helper state that Git stores between + /// invocations and replays on the next call, plus a continuation + /// flag that signals a non-final authentication step is expected. + /// + State = 1 << 0, +} + +/// +/// Helpers for parsing and advertising Git credential protocol capabilities. +/// +public static class GitCapabilitiesUtils +{ + /// + /// Parse a single capability name (e.g. "authtype") into a + /// flag. Unrecognized names parse to . + /// + /// + /// Names from Git are lowercase per the protocol; matching is done + /// case-insensitively for safety. New capability names should be added + /// here, mirroring the values added to . + /// + public static GitCapabilities ParseName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return GitCapabilities.None; + } + + // Names from Git are lowercase; parse case-insensitively for safety. + // New entries will be added here as each capability's input/output + // handling is implemented. + return name.ToLowerInvariant() switch + { + "state" => GitCapabilities.State, + _ => GitCapabilities.None, + }; + } + + /// + /// Render a single flag to its on-the-wire + /// protocol name (e.g. "authtype"). + /// + /// + /// The protocol name is always lowercase. New entries must be added here + /// in lockstep with new flag values to avoid + /// emitting an incorrect name to Git. + /// + public static string ToProtocolName(GitCapabilities capability) + { + // Add each flag's protocol name here as the capability is wired up. + // The default lowercase enum name is intentionally NOT used because + // some protocol names will not be a single token (e.g. authtype is fine + // but a hypothetical "PasswordExpiryUtc" would have to map to a + // protocol name distinct from its .NET enum name). + return capability switch + { + GitCapabilities.State => "state", + GitCapabilities.None => throw new ArgumentException( + "Cannot render the None capability to a protocol name.", + nameof(capability)), + _ => throw new ArgumentOutOfRangeException( + nameof(capability), + capability, + "No protocol name mapping is defined for the given capability."), + }; + } + + /// + /// Enumerate each individual flag set in + /// , rendered to its on-the-wire protocol name. + /// + /// + /// Returns an empty sequence for . Each + /// emitted name comes from . + /// + public static IEnumerable ToProtocolNames(GitCapabilities capabilities) + { + if (capabilities == GitCapabilities.None) + { + yield break; + } +#if NETFRAMEWORK + foreach (GitCapabilities flag in (GitCapabilities[])Enum.GetValues(typeof(GitCapabilities))) +#else + foreach (GitCapabilities flag in Enum.GetValues()) +#endif + { + if (flag == GitCapabilities.None) + { + continue; + } + + if ((capabilities & flag) == flag) + { + yield return ToProtocolName(flag); + } + } + } +} diff --git a/src/shared/Core/InputArguments.cs b/src/shared/Core/GitRequest.cs similarity index 56% rename from src/shared/Core/InputArguments.cs rename to src/shared/Core/GitRequest.cs index 626fc805d..d742650da 100644 --- a/src/shared/Core/InputArguments.cs +++ b/src/shared/Core/GitRequest.cs @@ -6,18 +6,20 @@ namespace GitCredentialManager { /// - /// Represents the input for a Git credential query such as get, erase, or store. + /// Represents the request for a single Git credential helper invocation (get / store / erase). /// /// - /// 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. + /// Surfaces the input streamed over standard input from Git, including the + /// protocol, host, and remote repository path, plus any negotiated protocol + /// capabilities. /// - public class InputArguments + public class GitRequest { private readonly IReadOnlyDictionary> _dict; + private GitCapabilities? _capabilities; + private IReadOnlyDictionary _state; - public InputArguments(IDictionary dict) + public GitRequest(IDictionary dict) { EnsureArgument.NotNull(dict, nameof(dict)); @@ -27,7 +29,7 @@ public InputArguments(IDictionary dict) ); } - public InputArguments(IDictionary> dict) + public GitRequest(IDictionary> dict) { EnsureArgument.NotNull(dict, nameof(dict)); @@ -35,7 +37,12 @@ public InputArguments(IDictionary> dict) _dict = new ReadOnlyDictionary>(dict); } - #region Common Arguments + /// + /// The set of Git credential protocol capabilities that Git itself advertised + /// it supports on this invocation. Unrecognized capability names are silently + /// discarded per the protocol specification. + /// + public GitCapabilities Capabilities => _capabilities ??= ParseCapabilities(); public string Protocol => GetArgumentOrDefault("protocol"); public string Host => GetArgumentOrDefault("host"); @@ -44,9 +51,16 @@ public InputArguments(IDictionary> dict) public string Password => GetArgumentOrDefault("password"); public IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); - #endregion - - #region Public Methods + /// + /// Opaque per-helper state Git is replaying from a previous invocation, + /// gated by the state capability. + /// + /// + /// Only entries with our recognized prefix () + /// are kept. The prefix is stripped from dictionary keys. Malformed entries + /// (missing =, invalid key/value characters) are silently discarded. + /// + public IReadOnlyDictionary State => _state ??= ParseState(); public string this[string key] { @@ -167,6 +181,60 @@ public Uri GetRemoteUri(bool includeUser = false) return null; } - #endregion + private GitCapabilities ParseCapabilities() + { + var caps = GitCapabilities.None; + + IList values = GetMultiArgumentOrDefault("capability"); + foreach (string name in values) + { + caps |= GitCapabilitiesUtils.ParseName(name); + } + + return caps; + } + + private IReadOnlyDictionary ParseState() + { + const string prefix = Constants.CredentialProtocol.GcmStatePrefix; + var result = new Dictionary(StringComparer.Ordinal); + + IList values = GetMultiArgumentOrDefault(Constants.CredentialProtocol.StateKey); + foreach (string entry in values) + { + int sep = entry.IndexOf('='); + if (sep <= 0) + { + // Malformed (no '=' or empty key): per the protocol + // "unrecognized attributes are silently discarded". + continue; + } + + string rawKey = entry.Substring(0, sep); + string value = entry.Substring(sep + 1); + + // Only consume our own namespace; let other helpers' state pass + // through us untouched (we never see it once Git stores it + // per-helper, but the protocol mandates this discipline). + if (!rawKey.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + string key = rawKey.Substring(prefix.Length); + + // Defensive: validate the post-prefix key and the value. + // Git should never hand us malformed entries, but skip any + // that wouldn't round-trip through our own emitter. + if (!GitStateValidation.IsValidKey(key) || !GitStateValidation.IsValidValue(value)) + { + continue; + } + + result[key] = value; + } + + return new ReadOnlyDictionary(result); + } } } diff --git a/src/shared/Core/GitResponse.cs b/src/shared/Core/GitResponse.cs new file mode 100644 index 000000000..6fc04c932 --- /dev/null +++ b/src/shared/Core/GitResponse.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace GitCredentialManager; + +/// +/// Represents the response from a host provider to a Git credential helper +/// invocation (typically a get). +/// +/// +/// +/// A response is exactly one of four shapes: +/// +/// +/// -- the provider produced a credential. +/// -- the provider produced a +/// credential AND signals that another round of authentication is +/// expected (emits continue=1; gated by the state +/// capability). +/// -- the provider declined and wants the whole +/// credential acquisition pipeline to stop (emits quit=1; Git aborts +/// the operation without a fallback interactive prompt). +/// -- the provider has nothing to contribute but +/// wants Git to continue trying other helpers or fall back to its built-in +/// interactive prompt (emits an empty response). +/// +/// +/// Attach opaque per-helper state to the response via +/// or the fluent . Both validate the key and value +/// against the wire-protocol rules and throw +/// on invalid input regardless of response shape. On and +/// shapes the entry is then silently discarded: state has +/// no meaning when no credential is being returned. +/// +/// +/// is an escape hatch for arbitrary extra +/// output keys that are not captured by the protocol capability. +/// +/// +public class GitResponse +{ + private readonly Dictionary _state = new(StringComparer.Ordinal); + private ReadOnlyDictionary _stateView; + + private GitResponse(ICredential credential, bool isContinue, bool isCancelled, bool isYielded) + { + // At most one of Continue, Cancel, Yield may be set (Ok is "none of them"). + if ((isContinue && isCancelled) || + (isContinue && isYielded) || + (isCancelled && isYielded)) + { + throw new ArgumentException( + "A response can be at most one of Continue, Cancel, or Yield."); + } + + bool hasCredential = credential is not null; + + if ((isCancelled || isYielded) && hasCredential) + { + throw new ArgumentException( + "A cancelled or yielded response cannot carry a credential.", + nameof(credential)); + } + + if (!isCancelled && !isYielded && !hasCredential) + { + throw new ArgumentNullException( + nameof(credential), + "A non-cancelled, non-yielded response must carry a credential. Use Cancel() or Yield() instead."); + } + + Credential = credential; + IsContinue = isContinue; + IsCancelled = isCancelled; + IsYielded = isYielded; + } + + /// + /// Construct a successful response carrying the given credential. + /// + /// Equivalent to . + public GitResponse(ICredential credential) + : this(credential, isContinue: false, isCancelled: false, isYielded: false) + { + } + + /// + /// Construct a successful response carrying the given credential. + /// + public static GitResponse Ok(ICredential credential) => + new GitResponse(credential, isContinue: false, isCancelled: false, isYielded: false); + + /// + /// Construct a successful response carrying the given credential and + /// signalling that another round of authentication is expected. + /// + /// + /// The command layer translates this into a continue=1 line on + /// standard output, gated by the state capability. Common in + /// multistage HTTP authentication (NTLM/Kerberos) and any flow where the + /// helper wants to be invoked again after the next server response. + /// + public static GitResponse Continue(ICredential credential) => + new GitResponse(credential, isContinue: true, isCancelled: false, isYielded: false); + + /// + /// Construct a cancellation response: the provider declined to produce a + /// credential for this request, and the whole credential acquisition + /// pipeline should stop. + /// + /// + /// The command layer translates a cancelled response into a quit=1 + /// line on standard output, which tells Git to abort the credential + /// acquisition pipeline immediately without falling back to an interactive + /// prompt. Any or state set on a + /// cancelled response are ignored. + /// + public static GitResponse Cancel() => + new GitResponse(credential: null, isContinue: false, isCancelled: true, isYielded: false); + + /// + /// Construct a yielded response: the provider has nothing to contribute + /// for this request but does not want to stop other helpers from being + /// tried (or Git's interactive prompt from being shown). + /// + /// + /// The command layer translates a yielded response into an empty response + /// on standard output (no credential fields, no quit signal). Git + /// then proceeds to the next helper in the chain or to its built-in + /// interactive prompt. Any or state + /// set on a yielded response are ignored. + /// + public static GitResponse Yield() => + new GitResponse(credential: null, isContinue: false, isCancelled: false, isYielded: true); + + /// + /// The credential resolved or generated for the request, or + /// when or is . + /// + public ICredential Credential { get; } + + /// + /// when the provider expects a further round of + /// authentication. The command layer translates this into a continue=1 + /// signal to Git (gated by the state capability). + /// + public bool IsContinue { get; } + + /// + /// when the provider declined to produce a credential + /// for this request and wants the whole credential acquisition pipeline to + /// stop. The command layer translates this into a quit=1 signal to + /// Git. + /// + public bool IsCancelled { get; } + + /// + /// when the provider has nothing to contribute but + /// does not want to stop the credential acquisition pipeline. The command + /// layer translates this into an empty response on standard output so Git + /// proceeds to the next helper or its interactive prompt. + /// + public bool IsYielded { get; } + + /// + /// Read-only view of the per-helper state to emit alongside the credential, + /// gated by the state capability. + /// + /// + /// + /// Keys are stored WITHOUT the ; + /// the command layer prepends it on the way out. + /// + /// + /// Mutate via or the fluent ; + /// reads go through the standard + /// surface (indexer, TryGetValue, ContainsKey, foreach). On + /// and responses, mutation + /// silently no-ops and this view stays empty. + /// + /// +#if NETFRAMEWORK + public IReadOnlyDictionary State => _stateView ??= new ReadOnlyDictionary(_state); +#else + public IReadOnlyDictionary State => _stateView ??= _state.AsReadOnly(); +#endif + + /// + /// Set a single state entry. + /// + /// + /// + /// Throws if the key or value contains invalid characters or an invalid key prefix. + /// + /// + /// On and responses, the entry is + /// silently discarded: state has no meaning when no credential is being returned. + /// + /// + public void SetState(string key, string value) + { + GitStateValidation.ValidateKey(key); + GitStateValidation.ValidateValue(value); + + if (IsCancelled || IsYielded) + { + return; + } + + _state[key] = value; + } + + /// + /// Fluent equivalent of : sets one state entry and + /// returns the same response for chaining. + /// + /// + /// Throws if the key or value contains invalid characters or an invalid key prefix. + /// + /// + /// Same silent-on-Cancel/Yield semantics as . + /// + public GitResponse WithState(string key, string value) + { + SetState(key, value); + return this; + } + + /// + /// Additional, untyped output to be emitted alongside the credential. + /// + /// + /// This is the legacy escape hatch for non-credential output. New code + /// should prefer typed properties on as they + /// are added. Ignored on cancelled and yielded responses. + /// + public IDictionary AdditionalProperties { get; set; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/shared/Core/GitStateValidation.cs b/src/shared/Core/GitStateValidation.cs new file mode 100644 index 000000000..36b1c534d --- /dev/null +++ b/src/shared/Core/GitStateValidation.cs @@ -0,0 +1,127 @@ +using System; + +namespace GitCredentialManager; + +/// +/// Validation rules for keys and values that Git Credential Manager emits +/// as part of the state[] protocol attribute. +/// +/// +/// +/// The Git credential protocol's wire format is line-oriented and key/value +/// separated by =. State entries are emitted as state[]={prefix}{key}={value}, +/// so neither key nor value may contain a newline or NUL, and the key may not contain an +/// additional = because the first one is what splits the field. +/// We also forbid the empty key and reject keys that already start with +/// because the framework adds +/// that prefix on the way out. +/// +/// +public static class GitStateValidation +{ + private const char LF = '\n'; + private const char NUL = '\0'; + private const char EQ = '='; + + /// + /// Returns if is a legal state + /// entry key (non-empty, no =/newline/NUL, no gcm. prefix). + /// + public static bool IsValidKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return false; + } + + foreach (char c in key) + { + if (c == EQ || c == LF || c == NUL) + { + return false; + } + } + + if (key.StartsWith(Constants.CredentialProtocol.GcmStatePrefix, StringComparison.Ordinal)) + { + return false; + } + + return true; + } + + /// + /// Returns if is a legal + /// state entry value (non-null, no newline/NUL). + /// + public static bool IsValidValue(string value) + { + if (value is null) + { + return false; + } + + foreach (char c in value) + { + if (c == LF || c == NUL) + { + return false; + } + } + + return true; + } + + /// + /// Throws if is not + /// a legal state entry key. + /// + public static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("State key cannot be null or whitespace.", nameof(key)); + } + + foreach (char c in key) + { + if (c == EQ || c == LF || c == NUL) + { + throw new ArgumentException( + "State key cannot contain '=', newline, or NUL characters.", + nameof(key)); + } + } + + if (key.StartsWith(Constants.CredentialProtocol.GcmStatePrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"State key cannot start with '{Constants.CredentialProtocol.GcmStatePrefix}'; " + + "the prefix is reserved and added automatically when state is emitted.", + nameof(key)); + } + } + + /// + /// Throws if is + /// not a legal state entry value. + /// + public static void ValidateValue(string value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value), "State value cannot be null."); + } + + foreach (char c in value) + { + if (c == LF || c == NUL) + { + throw new ArgumentException( + "State value cannot contain newline or NUL characters.", + nameof(value)); + } + } + } +} + diff --git a/src/shared/Core/HostProvider.cs b/src/shared/Core/HostProvider.cs index 40c3bfae7..f975636e5 100644 --- a/src/shared/Core/HostProvider.cs +++ b/src/shared/Core/HostProvider.cs @@ -27,11 +27,11 @@ public interface IHostProvider : IDisposable IEnumerable SupportedAuthorityIds { get; } /// - /// Determine if the are recognized by this particular Git hosting provider. + /// Determine if the is recognized by this particular Git hosting provider. /// - /// Input arguments of a Git credential query. + /// Git credential request. /// True if the provider supports the Git credential request, false otherwise. - bool IsSupported(InputArguments input); + bool IsSupported(GitRequest request); /// /// Determine if the identifies a recognized Git hosting provider. @@ -43,33 +43,21 @@ public interface IHostProvider : IDisposable /// /// Get a credential for accessing the remote Git repository on this hosting service. /// - /// Input arguments of a Git credential query. - /// A credential Git can use to authenticate to the remote repository. - Task GetCredentialAsync(InputArguments input); + /// Git credential request. + /// A response containing the credential Git can use to authenticate to the remote repository. + Task GetCredentialAsync(GitRequest request); /// /// Store a credential for accessing the remote Git repository on this hosting service. /// - /// Input arguments of a Git credential query. - Task StoreCredentialAsync(InputArguments input); + /// Git credential request. + Task StoreCredentialAsync(GitRequest request); /// /// Erase a stored credential for accessing the remote Git repository on this hosting service. /// - /// Input arguments of a Git credential query. - Task EraseCredentialAsync(InputArguments input); - } - - public class GetCredentialResult - { - public GetCredentialResult(ICredential credential) - { - Credential = credential; - } - - public ICredential Credential { get; set; } - public IDictionary AdditionalProperties { get; set; } - = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// Git credential request. + Task EraseCredentialAsync(GitRequest request); } /// @@ -94,7 +82,7 @@ protected HostProvider(ICommandContext context) public virtual IEnumerable SupportedAuthorityIds => Enumerable.Empty(); - public abstract bool IsSupported(InputArguments input); + public abstract bool IsSupported(GitRequest request); public virtual bool IsSupported(HttpResponseMessage response) { @@ -111,40 +99,40 @@ public virtual bool IsSupported(HttpResponseMessage response) /// potential re-authentication requests. /// /// - /// The default implementation returns the absolute URI formed by from the + /// The default implementation returns the absolute URI formed by from the /// without any userinfo component. Any trailing slashes are trimmed. /// /// - /// Input arguments of a Git credential query. + /// Git credential request. /// Credential service name. - public virtual string GetServiceName(InputArguments input) + public virtual string GetServiceName(GitRequest request) { // By default we assume the service name will be the absolute URI based on the - // input arguments from Git, without any userinfo part. - return input.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/'); + // request arguments from Git, without any userinfo part. + return request.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/'); } /// /// Create a new credential used for accessing the remote Git repository on this hosting service. /// - /// Input arguments of a Git credential query. + /// Git credential request. /// A credential Git can use to authenticate to the remote repository. - public abstract Task GenerateCredentialAsync(InputArguments input); + public abstract Task GenerateCredentialAsync(GitRequest request); - public virtual async Task GetCredentialAsync(InputArguments input) + public virtual async Task GetCredentialAsync(GitRequest request) { // Try and locate an existing credential in the OS credential store - string service = GetServiceName(input); - Context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={input.UserName}..."); + string service = GetServiceName(request); + Context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={request.UserName}..."); - ICredential credential = Context.CredentialStore.Get(service, input.UserName); + ICredential credential = Context.CredentialStore.Get(service, request.UserName); if (credential == null) { Context.Trace.WriteLine("No existing credentials found."); // No existing credential was found, create a new one Context.Trace.WriteLine("Creating new credential..."); - credential = await GenerateCredentialAsync(input); + credential = await GenerateCredentialAsync(request); Context.Trace.WriteLine("Credential created."); } else @@ -152,38 +140,38 @@ public virtual async Task GetCredentialAsync(InputArguments Context.Trace.WriteLine("Existing credential found."); } - return new GetCredentialResult(credential); + return new GitResponse(credential); } - public virtual Task StoreCredentialAsync(InputArguments input) + public virtual Task StoreCredentialAsync(GitRequest request) { - string service = GetServiceName(input); + string service = GetServiceName(request); // WIA-authentication is signaled to Git as an empty username/password pair // and we will get called to 'store' these WIA credentials. // We avoid storing empty credentials. - if (string.IsNullOrWhiteSpace(input.UserName) && string.IsNullOrWhiteSpace(input.Password)) + if (string.IsNullOrWhiteSpace(request.UserName) && string.IsNullOrWhiteSpace(request.Password)) { Context.Trace.WriteLine("Not storing empty credential."); } else { // Add or update the credential in the store. - Context.Trace.WriteLine($"Storing credential with service={service} account={input.UserName}..."); - Context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); + Context.Trace.WriteLine($"Storing credential with service={service} account={request.UserName}..."); + Context.CredentialStore.AddOrUpdate(service, request.UserName, request.Password); Context.Trace.WriteLine("Credential was successfully stored."); } return Task.CompletedTask; } - public virtual Task EraseCredentialAsync(InputArguments input) + public virtual Task EraseCredentialAsync(GitRequest request) { - string service = GetServiceName(input); + string service = GetServiceName(request); // Try to locate an existing credential - Context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={input.UserName}..."); - if (Context.CredentialStore.Remove(service, input.UserName)) + Context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={request.UserName}..."); + if (Context.CredentialStore.Remove(service, request.UserName)) { Context.Trace.WriteLine("Credential was successfully erased."); } diff --git a/src/shared/Core/HostProviderRegistry.cs b/src/shared/Core/HostProviderRegistry.cs index 7907ff7dc..5b06dd257 100644 --- a/src/shared/Core/HostProviderRegistry.cs +++ b/src/shared/Core/HostProviderRegistry.cs @@ -19,7 +19,7 @@ public enum HostProviderPriority /// /// Represents a collection of s which are selected based on Git credential query - /// . + /// . /// /// /// All registered s will be disposed when this is disposed. @@ -36,11 +36,11 @@ public interface IHostProviderRegistry : IDisposable /// /// Select a that can service the Git credential query based on the - /// . + /// . /// - /// Input arguments of a Git credential query. + /// Input arguments of a Git credential query. /// A host provider that can service the given query. - Task GetProviderAsync(InputArguments input); + Task GetProviderAsync(GitRequest request); } /// @@ -87,7 +87,7 @@ public void Register(IHostProvider hostProvider, HostProviderPriority priority) providers.Add(hostProvider); } - public async Task GetProviderAsync(InputArguments input) + public async Task GetProviderAsync(GitRequest request) { IHostProvider provider; @@ -148,7 +148,7 @@ public async Task GetProviderAsync(InputArguments input) // _context.Trace.WriteLine("Performing auto-detection of host provider."); - var uri = input.GetRemoteUri(); + var uri = request.GetRemoteUri(); if (uri is null) { throw new Trace2Exception(_context.Trace2, "Unable to detect host provider without a remote URL"); @@ -169,8 +169,8 @@ async Task MatchProviderAsync(HostProviderPriority priority, bool { _context.Trace.WriteLine($"Checking against {providers.Count} host providers registered with priority '{priority}'."); - // Try matching using the static Git input arguments first (cheap) - if (providers.TryGetFirst(x => x.IsSupported(input), out IHostProvider match)) + // Try matching using the static Git request arguments first (cheap) + if (providers.TryGetFirst(x => x.IsSupported(request), out IHostProvider match)) { return match; } diff --git a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs index 8f838f8b3..2b4100e75 100644 --- a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs +++ b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs @@ -20,9 +20,9 @@ public class GitHubHostProviderTests [InlineData("https://api.github.com", false)] [InlineData("https://api.gist.github.com", false)] [InlineData("https://foogist.github.com", false)] - public void GitHubHostProvider_IsGitHubDotCom(string input, bool expected) + public void GitHubHostProvider_IsGitHubDotCom(string request, bool expected) { - Assert.Equal(expected, GitHubHostProvider.IsGitHubDotCom(new Uri(input))); + Assert.Equal(expected, GitHubHostProvider.IsGitHubDotCom(new Uri(request))); } @@ -57,14 +57,14 @@ public void GitHubHostProvider_IsGitHubDotCom(string input, bool expected) [InlineData("https", "GiST.GitHub.My-Company-Server.com", true)] public void GitHubHostProvider_IsSupported(string protocol, string host, bool expected) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = host, }); var provider = new GitHubHostProvider(new TestCommandContext()); - Assert.Equal(expected, provider.IsSupported(input)); + Assert.Equal(expected, provider.IsSupported(request)); } [Theory] @@ -82,14 +82,14 @@ public void GitHubHostProvider_IsSupported(string protocol, string host, bool ex [InlineData("https", "GiST.GitHub.My.Company.Server.Com", "https://github.my.company.server.com")] public void GitHubHostProvider_GetCredentialServiceUrl(string protocol, string host, string expectedService) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = host, }); var provider = new GitHubHostProvider(new TestCommandContext()); - Assert.Equal(expectedService, GitHubHostProvider.GetServiceName(input)); + Assert.Equal(expectedService, GitHubHostProvider.GetServiceName(request)); } @@ -158,7 +158,7 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes_WithMetadat [Fact] public async Task GitHubHostProvider_GetCredentialAsync_NoCredentials_NoUserNoHeaders_PromptsUser() { - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = "https", @@ -177,7 +177,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_NoCredentials_NoUserNoHe var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(credential.Account, newCredential.Account); @@ -190,7 +190,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_NoCredentials_NoUserNoHe [Fact] public async Task GitHubHostProvider_GetCredentialAsync_InputUser_ReturnsCredentialForUser() { - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = "https", @@ -208,7 +208,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_InputUser_ReturnsCredent var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(result); @@ -219,7 +219,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_InputUser_ReturnsCredent [Fact] public async Task GitHubHostProvider_GetCredentialAsync_OneDomainAccount_ReturnsCredentialForRealmAccount() { - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = "https", @@ -238,7 +238,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_OneDomainAccount_Returns var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(result); @@ -249,7 +249,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_OneDomainAccount_Returns [Fact] public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_PromptForAccountAndReturnCredentialForAccount() { - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = "https", @@ -271,7 +271,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_P var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(result); @@ -287,7 +287,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_P [Fact] public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_PromptForAccountNewAccount() { - var input = new InputArguments( + var request = new GitRequest( new Dictionary { ["protocol"] = "https", @@ -315,7 +315,7 @@ public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_P var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.Equal(newCredential.Account, credential.Account); diff --git a/src/shared/GitHub.UI.Avalonia/Commands/SelectAccountCommandImpl.cs b/src/shared/GitHub.UI.Avalonia/Commands/SelectAccountCommandImpl.cs deleted file mode 100644 index ea4283653..000000000 --- a/src/shared/GitHub.UI.Avalonia/Commands/SelectAccountCommandImpl.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitHub.UI.ViewModels; -using GitHub.UI.Views; -using GitCredentialManager; -using GitCredentialManager.UI; - -namespace GitHub.UI.Commands -{ - public class SelectAccountCommandImpl : SelectAccountCommand - { - public SelectAccountCommandImpl(ICommandContext context) : base(context) { } - - protected override Task ShowAsync(SelectAccountViewModel viewModel, CancellationToken ct) - { - return AvaloniaUi.ShowViewAsync(viewModel, GetParentHandle(), ct); - } - } -} diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 07607dd4e..2b7c048a3 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -49,9 +49,9 @@ public GitHubHostProvider(ICommandContext context, IGitHubRestApi gitHubApi, IGi public IEnumerable SupportedAuthorityIds => GitHubAuthentication.AuthorityIds; - public bool IsSupported(InputArguments input) + public bool IsSupported(GitRequest request) { - if (input is null) + if (request is null) { return false; } @@ -59,14 +59,14 @@ public bool IsSupported(InputArguments input) // We do not support unencrypted HTTP communications to GitHub, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. - if (!StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") && - !StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) + if (!StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http") && + !StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "https")) { return false; } - // Split port number and hostname from host input argument - if (!input.TryGetHostAndPort(out string hostName, out _)) + // Split port number and hostname from host request argument + if (!request.TryGetHostAndPort(out string hostName, out _)) { return false; } @@ -108,10 +108,10 @@ public bool IsSupported(HttpResponseMessage response) return response.Headers.Contains("X-GitHub-Request-Id"); } - internal static /* for testing purposes */ string GetServiceName(InputArguments input) + internal static /* for testing purposes */ string GetServiceName(GitRequest request) { // Get the remote URI without user information - var baseUri = input.GetRemoteUri(includeUser: false); + var baseUri = request.GetRemoteUri(includeUser: false); return GetServiceName(baseUri); } @@ -125,15 +125,15 @@ private static string GetServiceName(Uri baseUri) return url.TrimEnd('/'); } - public async Task GetCredentialAsync(InputArguments input) + public async Task GetCredentialAsync(GitRequest request) { - string service = GetServiceName(input); - Uri remoteUri = input.GetRemoteUri(); + string service = GetServiceName(request); + Uri remoteUri = request.GetRemoteUri(); // If we have a specific username then we can try and find an existing credential for that account. // If not, we should check what accounts are available in the store and prompt the user if there // are multiple options. - string userName = input.UserName; + string userName = request.UserName; bool addAccount = false; bool filtered = false; if (string.IsNullOrWhiteSpace(userName)) @@ -145,7 +145,7 @@ public async Task GetCredentialAsync(InputArguments input) _context.Trace.WriteLine($" {account}"); } - filtered = FilterAccounts(remoteUri, input.WwwAuth, ref accounts); + filtered = FilterAccounts(remoteUri, request.WwwAuth, ref accounts); switch (accounts.Count) { @@ -190,7 +190,7 @@ public async Task GetCredentialAsync(InputArguments input) _context.Trace.WriteLine("Existing credential found."); } - return new GetCredentialResult(credential); + return new GitResponse(credential); } private bool FilterAccounts(Uri remoteUri, IEnumerable wwwAuth, ref IList accounts) @@ -241,35 +241,35 @@ out string enableFilteringStr return false; } - public virtual Task StoreCredentialAsync(InputArguments input) + public virtual Task StoreCredentialAsync(GitRequest request) { - string service = GetServiceName(input); + string service = GetServiceName(request); // WIA-authentication is signaled to Git as an empty username/password pair // and we will get called to 'store' these WIA credentials. // We avoid storing empty credentials. - if (string.IsNullOrWhiteSpace(input.UserName) && string.IsNullOrWhiteSpace(input.Password)) + if (string.IsNullOrWhiteSpace(request.UserName) && string.IsNullOrWhiteSpace(request.Password)) { _context.Trace.WriteLine("Not storing empty credential."); } else { // Add or update the credential in the store. - _context.Trace.WriteLine($"Storing credential with service={service} account={input.UserName}..."); - _context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); + _context.Trace.WriteLine($"Storing credential with service={service} account={request.UserName}..."); + _context.CredentialStore.AddOrUpdate(service, request.UserName, request.Password); _context.Trace.WriteLine("Credential was successfully stored."); } return Task.CompletedTask; } - public virtual Task EraseCredentialAsync(InputArguments input) + public virtual Task EraseCredentialAsync(GitRequest request) { - string service = GetServiceName(input); + string service = GetServiceName(request); // Try to locate an existing credential - _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={input.UserName}..."); - if (_context.CredentialStore.Remove(service, input.UserName)) + _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={request.UserName}..."); + if (_context.CredentialStore.Remove(service, request.UserName)) { _context.Trace.WriteLine("Credential was successfully erased."); } diff --git a/src/shared/GitLab.Tests/GitLabHostProviderTests.cs b/src/shared/GitLab.Tests/GitLabHostProviderTests.cs index c371ebf65..202c44bce 100644 --- a/src/shared/GitLab.Tests/GitLabHostProviderTests.cs +++ b/src/shared/GitLab.Tests/GitLabHostProviderTests.cs @@ -16,14 +16,14 @@ public class GitLabHostProviderTests [InlineData("https", "github.example.com", false)] public void GitLabHostProvider_IsSupported(string protocol, string host, bool expected) { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = protocol, ["host"] = host, }); var provider = new GitLabHostProvider(new TestCommandContext()); - Assert.Equal(expected, provider.IsSupported(input)); + Assert.Equal(expected, provider.IsSupported(request)); } [Fact] diff --git a/src/shared/GitLab/GitLabHostProvider.cs b/src/shared/GitLab/GitLabHostProvider.cs index f607589f4..e726a46c6 100644 --- a/src/shared/GitLab/GitLabHostProvider.cs +++ b/src/shared/GitLab/GitLabHostProvider.cs @@ -34,9 +34,9 @@ public GitLabHostProvider(ICommandContext context, IGitLabAuthentication gitLabA public override string Name => "GitLab"; - public override bool IsSupported(InputArguments input) + public override bool IsSupported(GitRequest request) { - if (input is null) + if (request is null) { return false; } @@ -44,19 +44,19 @@ public override bool IsSupported(InputArguments input) // We do not support unencrypted HTTP communications to GitLab, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. - if (!StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") && - !StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) + if (!StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http") && + !StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "https")) { return false; } - if (GitLabConstants.IsGitLabDotCom(input.GetRemoteUri())) + if (GitLabConstants.IsGitLabDotCom(request.GetRemoteUri())) { return true; } - // Split port number and hostname from host input argument - if (!input.TryGetHostAndPort(out string hostName, out _)) + // Split port number and hostname from host request argument + if (!request.TryGetHostAndPort(out string hostName, out _)) { return false; } @@ -70,7 +70,7 @@ public override bool IsSupported(InputArguments input) return true; } - if (input.WwwAuth.Any(x => x.Contains("realm=\"GitLab\""))) + if (request.WwwAuth.Any(x => x.Contains("realm=\"GitLab\""))) { return true; } @@ -90,13 +90,13 @@ public override bool IsSupported(HttpResponseMessage response) return response.Headers.Contains("X-Gitlab-Feature-Category"); } - public override async Task GenerateCredentialAsync(InputArguments input) + public override async Task GenerateCredentialAsync(GitRequest request) { ThrowIfDisposed(); // We should not allow unencrypted communication and should inform the user if (!Context.Settings.AllowUnsafeRemotes && - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http")) { throw new Trace2Exception(Context.Trace2, "Unencrypted HTTP is not recommended for GitLab. " + @@ -104,11 +104,11 @@ public override async Task GenerateCredentialAsync(InputArguments i $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); } - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); AuthenticationModes authModes = GetSupportedAuthenticationModes(remoteUri); - AuthenticationPromptResult promptResult = await _gitLabAuth.GetAuthenticationAsync(remoteUri, input.UserName, authModes); + AuthenticationPromptResult promptResult = await _gitLabAuth.GetAuthenticationAsync(remoteUri, request.UserName, authModes); switch (promptResult.AuthenticationMode) { @@ -117,7 +117,7 @@ public override async Task GenerateCredentialAsync(InputArguments i return promptResult.Credential; case AuthenticationModes.Browser: - return await GenerateOAuthCredentialAsync(input); + return await GenerateOAuthCredentialAsync(request); default: throw new ArgumentOutOfRangeException(nameof(promptResult)); @@ -179,11 +179,11 @@ internal AuthenticationModes GetSupportedAuthenticationModes(Uri targetUri) } // Stores OAuth tokens as a side effect - public override async Task GetCredentialAsync(InputArguments input) + public override async Task GetCredentialAsync(GitRequest request) { - string service = GetServiceName(input); - ICredential credential = Context.CredentialStore.Get(service, input.UserName); - if (credential?.Account == "oauth2" && await IsOAuthTokenExpired(input.GetRemoteUri(), credential.Password)) + string service = GetServiceName(request); + ICredential credential = Context.CredentialStore.Get(service, request.UserName); + if (credential?.Account == "oauth2" && await IsOAuthTokenExpired(request.GetRemoteUri(), credential.Password)) { Context.Trace.WriteLine("Removing expired OAuth access token..."); Context.CredentialStore.Remove(service, credential.Account); @@ -192,17 +192,17 @@ public override async Task GetCredentialAsync(InputArgument if (credential != null) { - return new GetCredentialResult(credential); + return new GitResponse(credential); } - string refreshService = GetRefreshTokenServiceName(input); - string refreshToken = Context.CredentialStore.Get(refreshService, input.UserName)?.Password; + string refreshService = GetRefreshTokenServiceName(request); + string refreshToken = Context.CredentialStore.Get(refreshService, request.UserName)?.Password; if (refreshToken != null) { Context.Trace.WriteLine("Refreshing OAuth token..."); try { - credential = await RefreshOAuthCredentialAsync(input, refreshToken); + credential = await RefreshOAuthCredentialAsync(request, refreshToken); } catch (Exception e) { @@ -210,7 +210,7 @@ public override async Task GetCredentialAsync(InputArgument } } - credential ??= await GenerateCredentialAsync(input); + credential ??= await GenerateCredentialAsync(request); if (credential is OAuthCredential oAuthCredential) { @@ -221,7 +221,7 @@ public override async Task GetCredentialAsync(InputArgument // store refresh token under a separate service Context.CredentialStore.AddOrUpdate(refreshService, oAuthCredential.Account, oAuthCredential.RefreshToken); } - return new GetCredentialResult(credential); + return new GitResponse(credential); } private async Task IsOAuthTokenExpired(Uri baseUri, string accessToken) @@ -261,15 +261,15 @@ public OAuthCredential(OAuth2TokenResult oAuth2TokenResult) string ICredential.Password => AccessToken; } - private async Task GenerateOAuthCredentialAsync(InputArguments input) + private async Task GenerateOAuthCredentialAsync(GitRequest request) { - OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaBrowserAsync(input.GetRemoteUri(), GitLabOAuthScopes); + OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaBrowserAsync(request.GetRemoteUri(), GitLabOAuthScopes); return new OAuthCredential(result); } - private async Task RefreshOAuthCredentialAsync(InputArguments input, string refreshToken) + private async Task RefreshOAuthCredentialAsync(GitRequest request, string refreshToken) { - OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaRefresh(input.GetRemoteUri(), refreshToken); + OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaRefresh(request.GetRemoteUri(), refreshToken); return new OAuthCredential(result); } @@ -279,18 +279,18 @@ protected override void ReleaseManagedResources() base.ReleaseManagedResources(); } - private string GetRefreshTokenServiceName(InputArguments input) + private string GetRefreshTokenServiceName(GitRequest request) { - var builder = new UriBuilder(GetServiceName(input)); + var builder = new UriBuilder(GetServiceName(request)); builder.Host = "oauth-refresh-token." + builder.Host; return builder.Uri.ToString(); } - public override Task EraseCredentialAsync(InputArguments input) + public override Task EraseCredentialAsync(GitRequest request) { // delete any refresh token too - Context.CredentialStore.Remove(GetRefreshTokenServiceName(input), "oauth2"); - return base.EraseCredentialAsync(input); + Context.CredentialStore.Remove(GetRefreshTokenServiceName(request), "oauth2"); + return base.EraseCredentialAsync(request); } } } diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index e05db1646..aeedf8d3e 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -21,7 +21,7 @@ public class AzureReposHostProviderTests [Fact] public void AzureReposProvider_IsSupported_AzureHost_UnencryptedHttp_ReturnsTrue() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "http", ["host"] = "dev.azure.com", @@ -32,13 +32,13 @@ public void AzureReposProvider_IsSupported_AzureHost_UnencryptedHttp_ReturnsTrue // We report that we support unencrypted HTTP here so that we can fail and // show a helpful error message in the call to `CreateCredentialAsync` instead. - Assert.True(provider.IsSupported(input)); + Assert.True(provider.IsSupported(request)); } [Fact] public void AzureReposProvider_IsSupported_VisualStudioHost_UnencryptedHttp_ReturnsTrue() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "http", ["host"] = "org.visualstudio.com", @@ -48,13 +48,13 @@ public void AzureReposProvider_IsSupported_VisualStudioHost_UnencryptedHttp_Retu // We report that we support unencrypted HTTP here so that we can fail and // show a helpful error message in the call to `CreateCredentialAsync` instead. - Assert.True(provider.IsSupported(input)); + Assert.True(provider.IsSupported(request)); } [Fact] public void AzureReposProvider_IsSupported_AzureHost_WithPath_ReturnsTrue() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -62,52 +62,52 @@ public void AzureReposProvider_IsSupported_AzureHost_WithPath_ReturnsTrue() }); var provider = new AzureReposHostProvider(new TestCommandContext()); - Assert.True(provider.IsSupported(input)); + Assert.True(provider.IsSupported(request)); } [Fact] public void AzureReposProvider_IsSupported_AzureHost_MissingPath_ReturnsTrue() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", }); var provider = new AzureReposHostProvider(new TestCommandContext()); - Assert.True(provider.IsSupported(input)); + Assert.True(provider.IsSupported(request)); } [Fact] public void AzureReposProvider_IsSupported_VisualStudioHost_ReturnsTrue() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "org.visualstudio.com", }); var provider = new AzureReposHostProvider(new TestCommandContext()); - Assert.True(provider.IsSupported(input)); + Assert.True(provider.IsSupported(request)); } [Fact] public void AzureReposProvider_IsSupported_VisualStudioHost_MissingOrgInHost_ReturnsFalse() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "visualstudio.com", }); var provider = new AzureReposHostProvider(new TestCommandContext()); - Assert.False(provider.IsSupported(input)); + Assert.False(provider.IsSupported(request)); } [Fact] public void AzureReposProvider_IsSupported_NonAzureRepos_ReturnsFalse() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", @@ -115,13 +115,13 @@ public void AzureReposProvider_IsSupported_NonAzureRepos_ReturnsFalse() }); var provider = new AzureReposHostProvider(new TestCommandContext()); - Assert.False(provider.IsSupported(input)); + Assert.False(provider.IsSupported(request)); } [Fact] public async Task AzureReposProvider_GetCredentialAsync_UnencryptedHttp_ThrowsException() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "http", ["host"] = "dev.azure.com", @@ -136,7 +136,7 @@ public async Task AzureReposProvider_GetCredentialAsync_UnencryptedHttp_ThrowsEx var provider = new AzureReposHostProvider(context, azDevOps, msAuth, authorityCache, userMgr); - await Assert.ThrowsAsync(() => provider.GetCredentialAsync(input)); + await Assert.ThrowsAsync(() => provider.GetCredentialAsync(request)); } [Fact] @@ -144,7 +144,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ { var urlAccount = "jane.doe"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "org.visualstudio.com", @@ -180,7 +180,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -193,7 +193,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ { var urlAccount = "jane.doe"; - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -230,7 +230,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -241,7 +241,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlOrgName_ReturnsCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -278,7 +278,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -289,7 +289,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_NoUser_ReturnsCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -326,7 +326,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -338,7 +338,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_BoundUser_ReturnsCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -376,7 +376,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -387,7 +387,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthority_NoUser_ReturnsCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -426,7 +426,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -437,7 +437,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit [Fact] public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_NoExistingPat_GeneratesCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -471,7 +471,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_No var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -482,7 +482,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_OrgInUserName_No [Fact] public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_GeneratesCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -517,7 +517,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -528,7 +528,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge [Fact] public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_ReturnsExistingCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -551,7 +551,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_Retu var provider = new AzureReposHostProvider(context, azDevOps, msAuth, authorityCache, userMgr); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -562,7 +562,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_Retu [Fact] public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsManagedIdCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -593,7 +593,7 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -608,7 +608,7 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM [Fact] public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Generic_ReturnsFederationOptions() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -646,7 +646,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Gener var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); - GetCredentialResult result = await provider.GetCredentialAsync(input); + GitResponse result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -667,7 +667,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Gener [Fact] public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GenericFileAssertion_ReadsFromFile() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -708,7 +708,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Gener var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); - GetCredentialResult result = await provider.GetCredentialAsync(input); + GitResponse result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -729,7 +729,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Gener [Fact] public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_ReturnsFederationOptions() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -767,7 +767,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_Re var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); - GetCredentialResult result = await provider.GetCredentialAsync(input); + GitResponse result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -788,7 +788,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_Re [Fact] public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHubActions_ReturnsFederationOptions() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -828,7 +828,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHu var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); - GetCredentialResult result = await provider.GetCredentialAsync(input); + GitResponse result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); @@ -850,7 +850,7 @@ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHu [Fact] public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() { - var input = new InputArguments(new Dictionary + var request = new GitRequest(new Dictionary { ["protocol"] = "https", ["host"] = "dev.azure.com", @@ -886,7 +886,7 @@ public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_Returns var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); - var result = await provider.GetCredentialAsync(input); + var result = await provider.GetCredentialAsync(request); ICredential credential = result.Credential; Assert.NotNull(credential); diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 9a916a236..db38dc4b7 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -52,9 +52,9 @@ public AzureReposHostProvider(ICommandContext context, IAzureDevOpsRestApi azDev public IEnumerable SupportedAuthorityIds => MicrosoftAuthentication.AuthorityIds; - public bool IsSupported(InputArguments input) + public bool IsSupported(GitRequest request) { - if (input is null) + if (request is null) { return false; } @@ -62,9 +62,9 @@ public bool IsSupported(InputArguments input) // We do not recommend unencrypted HTTP communications to Azure Repos, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. - return input.TryGetHostAndPort(out string hostName, out _) - && (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && + return request.TryGetHostAndPort(out string hostName, out _) + && (StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http") || + StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "https")) && UriHelpers.IsAzureDevOpsHost(hostName); } @@ -74,13 +74,13 @@ public bool IsSupported(HttpResponseMessage response) return false; } - public async Task GetCredentialAsync(InputArguments input) + public async Task GetCredentialAsync(GitRequest request) { if (UseManagedIdentity(out string mid)) { _context.Trace.WriteLine($"Getting Azure Access Token for managed identity {mid}..."); var azureResult = await _msAuth.GetTokenForManagedIdentityAsync(mid, AzureDevOpsConstants.AzureDevOpsResourceId); - return new GetCredentialResult( + return new GitResponse( new GitCredential(mid, azureResult.AccessToken) ); } @@ -89,7 +89,7 @@ public async Task GetCredentialAsync(InputArguments input) { _context.Trace.WriteLine($"Getting Azure Access Token using WIF (scenario: {fedOpts.Scenario})..."); var azureResult = await _msAuth.GetTokenUsingWorkloadFederationAsync(fedOpts, AzureDevOpsConstants.AzureDevOpsDefaultScopes); - return new GetCredentialResult( + return new GitResponse( new GitCredential(fedOpts.ClientId, azureResult.AccessToken) ); } @@ -98,16 +98,16 @@ public async Task GetCredentialAsync(InputArguments input) { _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); var azureResult = await _msAuth.GetTokenForServicePrincipalAsync(sp, AzureDevOpsConstants.AzureDevOpsDefaultScopes); - return new GetCredentialResult( + return new GitResponse( new GitCredential(sp.Id, azureResult.AccessToken) ); } if (UsePersonalAccessTokens()) { - Uri remoteWithUserUri = input.GetRemoteUri(includeUser: true); + Uri remoteWithUserUri = request.GetRemoteUri(includeUser: true); string service = GetServiceName(remoteWithUserUri); - string account = GetAccountNameForCredentialQuery(input); + string account = GetAccountNameForCredentialQuery(request); _context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={account}..."); @@ -118,7 +118,7 @@ public async Task GetCredentialAsync(InputArguments input) // No existing credential was found, create a new one _context.Trace.WriteLine("Creating new credential..."); - credential = await GeneratePersonalAccessTokenAsync(input); + credential = await GeneratePersonalAccessTokenAsync(request); _context.Trace.WriteLine("Credential created."); } else @@ -126,21 +126,21 @@ public async Task GetCredentialAsync(InputArguments input) _context.Trace.WriteLine("Existing credential found."); } - return new GetCredentialResult(credential); + return new GitResponse(credential); } else { // Include the username request here so that we may use it as an override // for user account lookups when getting Azure Access Tokens. - var azureResult = await GetAzureAccessTokenAsync(input); + var azureResult = await GetAzureAccessTokenAsync(request); var azureCredential = new GitCredential(azureResult.AccountUpn, azureResult.AccessToken); - return new GetCredentialResult(azureCredential); + return new GitResponse(azureCredential); } } - public Task StoreCredentialAsync(InputArguments input) + public Task StoreCredentialAsync(GitRequest request) { - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); if (UseManagedIdentity(out _)) { @@ -160,26 +160,26 @@ public Task StoreCredentialAsync(InputArguments input) // We always store credentials against the given username argument for // both vs.com and dev.azure.com-style URLs. - string account = input.UserName; + string account = request.UserName; // Add or update the credential in the store. _context.Trace.WriteLine($"Storing credential with service={service} account={account}..."); - _context.CredentialStore.AddOrUpdate(service, account, input.Password); + _context.CredentialStore.AddOrUpdate(service, account, request.Password); _context.Trace.WriteLine("Credential was successfully stored."); } else { string orgName = UriHelpers.GetOrganizationName(remoteUri); - _context.Trace.WriteLine($"Signing user {input.UserName} in to organization '{orgName}'..."); - _bindingManager.SignIn(orgName, input.UserName); + _context.Trace.WriteLine($"Signing user {request.UserName} in to organization '{orgName}'..."); + _bindingManager.SignIn(orgName, request.UserName); } return Task.CompletedTask; } - public Task EraseCredentialAsync(InputArguments input) + public Task EraseCredentialAsync(GitRequest request) { - Uri remoteUri = input.GetRemoteUri(); + Uri remoteUri = request.GetRemoteUri(); if (UseManagedIdentity(out _)) { @@ -196,7 +196,7 @@ public Task EraseCredentialAsync(InputArguments input) else if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); - string account = GetAccountNameForCredentialQuery(input); + string account = GetAccountNameForCredentialQuery(request); // Try to locate an existing credential _context.Trace.WriteLine( @@ -230,10 +230,10 @@ protected override void ReleaseManagedResources() base.ReleaseManagedResources(); } - private void ThrowIfUnsafeRemote(InputArguments input) + private void ThrowIfUnsafeRemote(GitRequest request) { if (!_context.Settings.AllowUnsafeRemotes && - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + StringComparer.OrdinalIgnoreCase.Equals(request.Protocol, "http")) { throw new Trace2Exception(_context.Trace2, "Unencrypted HTTP is not recommended for Azure Repos. " + @@ -242,12 +242,12 @@ private void ThrowIfUnsafeRemote(InputArguments input) } } - private async Task GeneratePersonalAccessTokenAsync(InputArguments input) + private async Task GeneratePersonalAccessTokenAsync(GitRequest request) { ThrowIfDisposed(); - ThrowIfUnsafeRemote(input); + ThrowIfUnsafeRemote(request); - Uri remoteUserUri = input.GetRemoteUri(includeUser: true); + Uri remoteUserUri = request.GetRemoteUri(includeUser: true); Uri orgUri = UriHelpers.CreateOrganizationUri(remoteUserUri, out _); // Determine the MS authentication authority for this organization @@ -283,19 +283,19 @@ private async Task GeneratePersonalAccessTokenAsync(InputArguments return new GitCredential(result.AccountUpn, pat); } - private async Task GetAzureAccessTokenAsync(InputArguments input) + private async Task GetAzureAccessTokenAsync(GitRequest request) { - ThrowIfUnsafeRemote(input); + ThrowIfUnsafeRemote(request); - Uri remoteWithUserUri = input.GetRemoteUri(includeUser: true); - string userName = input.UserName; + Uri remoteWithUserUri = request.GetRemoteUri(includeUser: true); + string userName = request.UserName; Uri orgUri = UriHelpers.CreateOrganizationUri(remoteWithUserUri, out string orgName); _context.Trace.WriteLine($"Determining Microsoft Authentication authority for Azure DevOps organization '{orgName}'..."); - if (TryGetAuthorityFromHeaders(input.WwwAuth, out string authAuthority)) + if (TryGetAuthorityFromHeaders(request.WwwAuth, out string authAuthority)) { - _context.Trace.WriteLine("Authority was found in WWW-Authenticate headers from Git input."); + _context.Trace.WriteLine("Authority was found in WWW-Authenticate headers from Git request."); } else { @@ -451,9 +451,9 @@ private static string GetServiceName(Uri remoteUri) throw new InvalidOperationException("Host is not Azure DevOps."); } - private static string GetAccountNameForCredentialQuery(InputArguments input) + private static string GetAccountNameForCredentialQuery(GitRequest request) { - if (!input.TryGetHostAndPort(out string hostName, out _)) + if (!request.TryGetHostAndPort(out string hostName, out _)) { throw new InvalidOperationException("Failed to parse host name and/or port"); } @@ -474,8 +474,8 @@ private static string GetAccountNameForCredentialQuery(InputArguments input) if (UriHelpers.IsVisualStudioComHost(hostName)) { // If we're given a username for the vs.com-style URLs we can and should respect any - // specified username in the remote URL/input arguments. - return input.UserName; + // specified username in the remote URL/request arguments. + return request.UserName; } throw new InvalidOperationException("Host is not Azure DevOps."); diff --git a/src/shared/Microsoft.AzureRepos/UriHelpers.cs b/src/shared/Microsoft.AzureRepos/UriHelpers.cs index 3d9a608a9..a200f4228 100644 --- a/src/shared/Microsoft.AzureRepos/UriHelpers.cs +++ b/src/shared/Microsoft.AzureRepos/UriHelpers.cs @@ -35,13 +35,13 @@ public static string CombinePath(string basePath, string path) /// /// Check if the hostname is the legacy Azure DevOps hostname (*.visualstudio.com). /// - /// Git query arguments. + /// Git query arguments. /// True if the hostname is the legacy Azure DevOps host, false otherwise. - public static bool IsVisualStudioComHost(InputArguments input) + public static bool IsVisualStudioComHost(GitRequest request) { - EnsureArgument.NotNull(input, nameof(input)); + EnsureArgument.NotNull(request, nameof(request)); - if (!input.TryGetHostAndPort(out string hostName, out _)) + if (!request.TryGetHostAndPort(out string hostName, out _)) { throw new InvalidOperationException("Host name and/or port is invalid."); } @@ -63,13 +63,13 @@ public static bool IsVisualStudioComHost(string host) /// /// Check if the hostname is the new Azure DevOps hostname (dev.azure.com). /// - /// Git query arguments. + /// Git query arguments. /// True if the hostname is the new Azure DevOps host, false otherwise. - public static bool IsDevAzureComHost(InputArguments input) + public static bool IsDevAzureComHost(GitRequest request) { - EnsureArgument.NotNull(input, nameof(input)); + EnsureArgument.NotNull(request, nameof(request)); - if (!input.TryGetHostAndPort(out string hostName, out _)) + if (!request.TryGetHostAndPort(out string hostName, out _)) { throw new InvalidOperationException("Host name and/or port is invalid."); } @@ -111,14 +111,14 @@ public static string GetOrganizationName(Uri remoteUri) /// Azure DevOps organization name. /// Azure DevOps organization URI /// - /// Thrown if is null or white space. + /// Thrown if is null or white space. /// - /// Thrown if is null or white space. + /// Thrown if is null or white space. /// - /// Thrown if is not an Azure DevOps hostname. + /// Thrown if is not an Azure DevOps hostname. /// - /// Thrown if both of or - /// are null or white space when is an Azure-style URL + /// Thrown if both of or + /// are null or white space when is an Azure-style URL /// ('dev.azure.com' rather than '*.visualstudio.com'). /// public static Uri CreateOrganizationUri(Uri remoteUri, out string orgName) diff --git a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs index a1a211bc6..321fe1010 100644 --- a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs +++ b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs @@ -8,11 +8,11 @@ public class TestHostProvider : HostProvider public TestHostProvider(ICommandContext context) : base(context) { } - public Func IsSupportedFunc { get; set; } + public Func IsSupportedFunc { get; set; } public string LegacyAuthorityIdValue { get; set; } - public Func GenerateCredentialFunc { get; set; } + public Func GenerateCredentialFunc { get; set; } #region HostProvider @@ -22,11 +22,11 @@ public TestHostProvider(ICommandContext context) public string LegacyAuthorityId => LegacyAuthorityIdValue; - public override bool IsSupported(InputArguments input) => IsSupportedFunc(input); + public override bool IsSupported(GitRequest request) => IsSupportedFunc(request); - public override Task GenerateCredentialAsync(InputArguments input) + public override Task GenerateCredentialAsync(GitRequest request) { - return Task.FromResult(GenerateCredentialFunc(input)); + return Task.FromResult(GenerateCredentialFunc(request)); } #endregion diff --git a/src/shared/TestInfrastructure/Objects/TestHostProviderRegistry.cs b/src/shared/TestInfrastructure/Objects/TestHostProviderRegistry.cs index a35f89e20..81732c6a9 100644 --- a/src/shared/TestInfrastructure/Objects/TestHostProviderRegistry.cs +++ b/src/shared/TestInfrastructure/Objects/TestHostProviderRegistry.cs @@ -12,7 +12,7 @@ void IHostProviderRegistry.Register(IHostProvider hostProvider, HostProviderPrio { } - Task IHostProviderRegistry.GetProviderAsync(InputArguments input) + Task IHostProviderRegistry.GetProviderAsync(GitRequest request) { return Task.FromResult(Provider); }