From eeccd46373fb96765c5d66f3c4e93f150fd71876 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:32:45 +0000 Subject: [PATCH 01/54] build(deps): bump actions/setup-dotnet from 5.1.0 to 5.2.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.1.0...v5.2.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2fd38db60b..34f37972ae 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index c59f623f73..0f7967da32 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x @@ -76,7 +76,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x @@ -120,7 +120,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x From 32e7ff77d46c164870f3b1fc598f2cbff1184838 Mon Sep 17 00:00:00 2001 From: Bartosz Zakrzewski Date: Tue, 10 Mar 2026 16:41:20 +0100 Subject: [PATCH 02/54] Added support for cachyos Its arch anyway, so to branch I added to interpret cachyos same as arch linux. --- src/linux/Packaging.Linux/install-from-source.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index 1a8ede9382..db95ced8be 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -220,7 +220,7 @@ case "$distribution" in ensure_dotnet_installed ;; - arch) + arch | cachyos) print_unsupported_distro "WARNING" "$distribution" # --noconfirm required when running from container From 42bf6e96f6ecec4f7cd61416ff6c26838012c019 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 11:54:06 +0100 Subject: [PATCH 03/54] github/gitlab: use correct param order When using a custom credential UI for either GitHub or GitLab the commands are mixing up the optional URL and user name args. This is because the positional args of the `ExecuteAsync` methods was not the same as the `SetHandler`. Swap the args to fix this. Signed-off-by: Matthew John Cheetham --- src/shared/GitHub/UI/Commands/CredentialsCommand.cs | 2 +- src/shared/GitLab/UI/Commands/CredentialsCommand.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs index f14b3cb3ec..45c6cfd7fe 100644 --- a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs @@ -38,7 +38,7 @@ protected CredentialsCommand(ICommandContext context) this.SetHandler(ExecuteAsync, url, userName, basic, browser, device, pat, all); } - private async Task ExecuteAsync(string userName, string enterpriseUrl, + private async Task ExecuteAsync(string enterpriseUrl, string userName, bool basic, bool browser, bool device, bool pat, bool all) { var viewModel = new CredentialsViewModel(Context.SessionManager, Context.ProcessManager) diff --git a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs index 1c1995a8db..02a0f78180 100644 --- a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs @@ -35,7 +35,7 @@ protected CredentialsCommand(ICommandContext context) this.SetHandler(ExecuteAsync, url, userName, basic, browser, pat, all); } - private async Task ExecuteAsync(string userName, string url, bool basic, bool browser, bool pat, bool all) + private async Task ExecuteAsync(string url, string userName, bool basic, bool browser, bool pat, bool all) { var viewModel = new CredentialsViewModel(Context.SessionManager) { From 51a237938fd41fe1663a05a6df32802df503c7c4 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 11:56:26 +0100 Subject: [PATCH 04/54] streamextensions: fix a bug in multi-var reset handling The multi-dictionary writer normalises value lists to honor reset semantics (empty values clear prior entries). However, when only one normalised value remains, it writes the first element from the original list instead of the normalised list. If the list contains an empty reset marker followed by a single valid value, the output will incorrectly emit the pre-reset value (or an empty string), violating the protocol and potentially leaking stale data that should have been cleared. Fix the issue and extend the unit tests to cover this shape. Signed-off-by: Matthew John Cheetham --- src/shared/Core.Tests/StreamExtensionsTests.cs | 5 +++-- src/shared/Core/StreamExtensions.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/shared/Core.Tests/StreamExtensionsTests.cs b/src/shared/Core.Tests/StreamExtensionsTests.cs index 09153ad269..b72874ba91 100644 --- a/src/shared/Core.Tests/StreamExtensionsTests.cs +++ b/src/shared/Core.Tests/StreamExtensionsTests.cs @@ -381,12 +381,13 @@ public void StreamExtensions_WriteDictionary_MultiEntriesWithEmpty_WritesKVPList { ["a"] = new[] {"1", "2", "", "3", "4"}, ["b"] = new[] {"5"}, - ["c"] = new[] {"6", "7", ""} + ["c"] = new[] {"6", "7", ""}, + ["d"] = new[] {"8", "", "9"} }; string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); - Assert.Equal("a[]=3\na[]=4\nb=5\n\n", output); + Assert.Equal("a[]=3\na[]=4\nb=5\nd=9\n\n", output); } #endregion diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs index 7ff338f5ab..beb85699be 100644 --- a/src/shared/Core/StreamExtensions.cs +++ b/src/shared/Core/StreamExtensions.cs @@ -179,7 +179,7 @@ public static void WriteDictionary(this TextWriter writer, IDictionary Date: Tue, 31 Mar 2026 11:59:31 +0100 Subject: [PATCH 05/54] github: handle empty domain or enterprise hints If the WWW-Authenticate header from GitHub is missing a domain or enterprise hint we'd be hitting a null-reference exception when calculating a hash code for the `GitHubAuthChallenge`. Fix this. Signed-off-by: Matthew John Cheetham --- src/shared/GitHub/GitHubAuthChallenge.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/shared/GitHub/GitHubAuthChallenge.cs b/src/shared/GitHub/GitHubAuthChallenge.cs index de3afbdbda..1b33330c91 100644 --- a/src/shared/GitHub/GitHubAuthChallenge.cs +++ b/src/shared/GitHub/GitHubAuthChallenge.cs @@ -107,7 +107,15 @@ public override bool Equals(object obj) public override int GetHashCode() { - return Domain.GetHashCode() * 1019 ^ - Enterprise.GetHashCode() * 337; + int domainHash = Domain is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(Domain); + + int enterpriseHash = Enterprise is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(Enterprise); + + return (domainHash * 1019) ^ + (enterpriseHash * 337); } } From 453ae23fe265b2099f1705481001f6d7d7834ed4 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:00:51 +0100 Subject: [PATCH 06/54] github: do not filter accounts outside of dotcom Outside of GitHub.com we should not filter accounts. The `FilterAccounts` method was detecting and logging this, but didn't actually stop filtering! Signed-off-by: Matthew John Cheetham --- src/shared/GitHub/GitHubHostProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 06afd95924..07607dd4e4 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -198,6 +198,7 @@ private bool FilterAccounts(Uri remoteUri, IEnumerable wwwAuth, ref ILis if (!IsGitHubDotCom(remoteUri)) { _context.Trace.WriteLine("No account filtering outside of GitHub.com."); + return false; } // Allow the user to disable account filtering until this feature stabilises. From 817f5e3609ac3a51718e485f5e506b3e77f3d745 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:02:08 +0100 Subject: [PATCH 07/54] diagnose: fix network diag to await HTTP requests The network diagnostic failed to correctly await the test HTTP requests, and also did not return and await a `Task` (to capture exceptions). Signed-off-by: Matthew John Cheetham --- .../Core.Tests/Commands/DiagnoseCommandTests.cs | 13 +++++++------ src/shared/Core/Diagnostics/NetworkingDiagnostic.cs | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs index 0118e9d855..42f5cedc7b 100644 --- a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs +++ b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Security.AccessControl; using System.Text; +using System.Threading.Tasks; using GitCredentialManager.Diagnostics; using GitCredentialManager.Tests.Objects; using Xunit; @@ -11,7 +12,7 @@ namespace Core.Tests.Commands; public class DiagnoseCommandTests { [Fact] - public void NetworkingDiagnostic_SendHttpRequest_Primary_OK() + public async Task NetworkingDiagnostic_SendHttpRequest_Primary_OK() { var primaryUriString = "http://example.com"; var sb = new StringBuilder(); @@ -24,14 +25,14 @@ public void NetworkingDiagnostic_SendHttpRequest_Primary_OK() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); Assert.Contains(expected, sb.ToString()); } [Fact] - public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() + public async Task NetworkingDiagnostic_SendHttpRequest_Backup_OK() { var primaryUriString = "http://example.com"; var backupUriString = "http://httpforever.com"; @@ -48,7 +49,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1); @@ -56,7 +57,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() } [Fact] - public void NetworkingDiagnostic_SendHttpRequest_No_Network() + public async Task NetworkingDiagnostic_SendHttpRequest_No_Network() { var primaryUriString = "http://example.com"; var backupUriString = "http://httpforever.com"; @@ -73,7 +74,7 @@ public void NetworkingDiagnostic_SendHttpRequest_No_Network() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1); diff --git a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs index 50ab5b4dab..c49104ea81 100644 --- a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs +++ b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs @@ -29,7 +29,7 @@ protected override async Task RunInternalAsync(StringBuilder log, IList RunInternalAsync(StringBuilder log, IList { TestHttpUri, TestHttpUriFallback }) { From 774af8ea9ef83fbbee38e806c436425c412a5ae8 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:03:24 +0100 Subject: [PATCH 08/54] macos: add die function to notarize.sh script Add the missing die function. Signed-off-by: Matthew John Cheetham --- src/osx/Installer.Mac/notarize.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/osx/Installer.Mac/notarize.sh b/src/osx/Installer.Mac/notarize.sh index 9315d688af..f3aa55d00e 100755 --- a/src/osx/Installer.Mac/notarize.sh +++ b/src/osx/Installer.Mac/notarize.sh @@ -1,4 +1,8 @@ #!/bin/bash +die () { + echo "$*" >&2 + exit 1 +} for i in "$@" do From 66c6ef06d1c1fcfefa9e42076f65bdbee04d97d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:32:43 +0000 Subject: [PATCH 09/54] build(deps): bump actions/github-script from 8 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/maintainer-absence.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maintainer-absence.yml b/.github/workflows/maintainer-absence.yml index 20e6694e79..3de79e6a12 100644 --- a/.github/workflows/maintainer-absence.yml +++ b/.github/workflows/maintainer-absence.yml @@ -18,7 +18,7 @@ jobs: name: create-issue runs-on: ubuntu-latest steps: - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 with: script: | const startDate = new Date('${{ github.event.inputs.startDate }}'); From b47259ca54fedfb82df61a0d550037801cec60f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:21:23 +0000 Subject: [PATCH 10/54] Bump Tmds.DBus.Protocol from 0.16.0 to 0.21.3 --- updated-dependencies: - dependency-name: Tmds.DBus.Protocol dependency-version: 0.21.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 1 + src/shared/Core/Core.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index a836e1bcaf..d1e002d856 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj index cdfd08deb1..d316df9921 100644 --- a/src/shared/Core/Core.csproj +++ b/src/shared/Core/Core.csproj @@ -32,6 +32,7 @@ + From 265c4ac311b41c63de82b7fe58a0b8a281df0f8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:40:59 +0000 Subject: [PATCH 11/54] Initial plan From 95502ae25ff9b03460fdf81436b6ae92a2b580d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:53:13 +0000 Subject: [PATCH 12/54] Add dark theme support to OAuth authentication response pages Agent-Logs-Url: https://github.com/git-ecosystem/git-credential-manager/sessions/00d6d223-97c2-4e72-844b-23a7b2b14b63 Co-authored-by: mjcheetham <5658207+mjcheetham@users.noreply.github.com> --- .../BitbucketResources.resx | 19 ++++++++++++- .../OAuth/OAuth2SystemWebBrowser.cs | 8 ++++-- src/shared/GitHub/GitHubResources.resx | 28 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx index d7e6058e85..e0f247248b 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx +++ b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx @@ -22,10 +22,19 @@ + Bitbucket Authentication +
@@ -53,11 +62,19 @@ + Bitbucket Authentication - +
diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs index 34d6cfbe72..05843f9df2 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs @@ -10,12 +10,16 @@ namespace GitCredentialManager.Authentication.OAuth public class OAuth2WebBrowserOptions { internal const string DefaultSuccessHtml = @" - + + Authentication successful

Authentication successful

You can now close this page.

"; internal const string DefaultFailureHtmlFormat = @" - + + Authentication failed

Authentication failed

Error:
{0}
diff --git a/src/shared/GitHub/GitHubResources.resx b/src/shared/GitHub/GitHubResources.resx index a5348d617b..3972d47791 100644 --- a/src/shared/GitHub/GitHubResources.resx +++ b/src/shared/GitHub/GitHubResources.resx @@ -20,6 +20,7 @@ + Git Credential Manager - Authentication Succeeded
@@ -75,6 +89,7 @@ p { + Git Credential Manager - Authentication Failed
From 50cdad490eef4afe5c5fa337f9338f42299cae34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:18:51 +0000 Subject: [PATCH 13/54] Fix Bitbucket dark mode footer-logo background Agent-Logs-Url: https://github.com/git-ecosystem/git-credential-manager/sessions/132c1df8-8b2a-4889-829a-816416100c64 Co-authored-by: mjcheetham <5658207+mjcheetham@users.noreply.github.com> --- src/shared/Atlassian.Bitbucket/BitbucketResources.resx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx index e0f247248b..165edd0c8a 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx +++ b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx @@ -33,6 +33,7 @@ .aui-page-panel, .aui-page-panel-inner, .aui-page-panel-content { background: #161B22 !important; color: #C9D1D9 !important; border-color: #30363D !important; } h1, h2, h3, h4, h5, h6, p { color: #C9D1D9 !important; } a { color: #58A6FF !important; } + #footer, #footer-logo { background: #0D1117 !important; color: #C9D1D9 !important; } } @@ -73,6 +74,7 @@ .aui-page-panel, .aui-page-panel-inner, .aui-page-panel-content {{ background: #161B22 !important; color: #C9D1D9 !important; border-color: #30363D !important; }} h1, h2, h3, h4, h5, h6, p, dt, dd {{ color: #C9D1D9 !important; }} a {{ color: #58A6FF !important; }} + #footer, #footer-logo {{ background: #0D1117 !important; color: #C9D1D9 !important; }} }} From bb004169d968e0c332403dbeb2aa6185c0eaea18 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 23 Apr 2026 13:16:16 +0100 Subject: [PATCH 14/54] http: fix SYSLIB0057 warning for X509Certificate2Collection.Import Use ImportFromPemFile on modern .NET to resolve the SYSLIB0057 deprecation warning, while keeping the original Import call on .NET Framework where the new API is not available. Signed-off-by: Matthew John Cheetham --- src/shared/Core/HttpClientFactory.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/shared/Core/HttpClientFactory.cs b/src/shared/Core/HttpClientFactory.cs index c48e277e50..d66fad39f9 100644 --- a/src/shared/Core/HttpClientFactory.cs +++ b/src/shared/Core/HttpClientFactory.cs @@ -130,7 +130,11 @@ public HttpClient CreateClient() // Import the custom certs X509Certificate2Collection certBundle = new X509Certificate2Collection(); +#if NETFRAMEWORK certBundle.Import(certBundlePath); +#else + certBundle.ImportFromPemFile(certBundlePath); +#endif try { From 5d850554b11fad55fa54e4df333d610f33c172af Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:04:35 +0100 Subject: [PATCH 15/54] git: drain stderr on IsInsideRepository When suppressStreams is true, Git's stderr is redirected to a pipe but then we only read stdout before waiting for exit. If Git writes lots to stderr (for example when tracing is enabled), the stderr pipe can fill, causing the Git process to block and GCM to hang while checking repository state. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Git.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/shared/Core/Git.cs b/src/shared/Core/Git.cs index 0c58e0159d..cef57e47d9 100644 --- a/src/shared/Core/Git.cs +++ b/src/shared/Core/Git.cs @@ -146,6 +146,15 @@ private string GetCurrentRepositoryInternal(bool suppressStreams) } git.Start(Trace2ProcessClass.Git); + + // Drain and throw away stderr asynchronously to avoid a deadlock + // if the child process fills the stderr pipe buffer. + if (suppressStreams) + { + git.Process.ErrorDataReceived += (_, _) => { }; + git.Process.BeginErrorReadLine(); + } + string data = git.StandardOutput.ReadToEnd(); git.WaitForExit(); From 86fd16e30e061f725a0b8f6eee54b559328c62bc Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:07:24 +0100 Subject: [PATCH 16/54] windows: fix layout.ps1 if symboloutput is not set If SymbolOutput is not set then there's a bug whereby we try and trim the end '/' and '\' characters on a null value. Guard against this. Signed-off-by: Matthew John Cheetham --- src/windows/Installer.Windows/layout.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/windows/Installer.Windows/layout.ps1 b/src/windows/Installer.Windows/layout.ps1 index 3b1624896c..53646764a4 100644 --- a/src/windows/Installer.Windows/layout.ps1 +++ b/src/windows/Installer.Windows/layout.ps1 @@ -3,7 +3,10 @@ param ([Parameter(Mandatory)] $Configuration, [Parameter(Mandatory)] $Output, $R # Trim trailing slashes from output paths $Output = $Output.TrimEnd('\','/') -$SymbolOutput = $SymbolOutput.TrimEnd('\','/') + +if ($SymbolOutput) { + $SymbolOutput = $SymbolOutput.TrimEnd('\','/') +} Write-Output "Output: $Output" From 2226933756ed353903fd23021059af1f9478950f Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:10:31 +0100 Subject: [PATCH 17/54] oauth: pass cancellation token to in-proc device code UI In the OAuth device-code flow, the in-proc UI path the calling logic creates a `CancellationTokenSource` and later calls `Cancel()` on that CTS to close the dialog once the token is obtained (or the user cancels). However, `ShowDeviceCodeViaUiAsync` disregards the provided cancellation token and passes `CancellationToken.None` into `AvaloniaUi.ShowViewAsync`. Pass the cancellation token correctly. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Authentication/OAuthAuthentication.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs index a8de4ecb6b..792ba40ece 100644 --- a/src/shared/Core/Authentication/OAuthAuthentication.cs +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -247,7 +247,7 @@ private Task ShowDeviceCodeViaUiAsync(OAuth2DeviceCodeResult dcr, CancellationTo VerificationUrl = dcr.VerificationUri.ToString(), }; - return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), CancellationToken.None); + return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), ct); } private Task ShowDeviceCodeViaHelperAsync( From 91362ebeb984fa6ed1066f099d45c7127c33f518 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:19:24 +0100 Subject: [PATCH 18/54] trace2: fix main thread identification The `Thread::ManagedTheadId` always starts with the entry thread as `1` and not `0`. https://github.com/dotnet/runtime/blob/790e8a525a0f76b8ad755c12e95b7f8770195d67/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Threading/ManagedThreadId.cs#L181 Fix this in Trace2. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Trace2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Trace2.cs b/src/shared/Core/Trace2.cs index 535812ea8b..e7f048ca27 100644 --- a/src/shared/Core/Trace2.cs +++ b/src/shared/Core/Trace2.cs @@ -640,7 +640,7 @@ private void WriteMessage(Trace2Message message) private static string BuildThreadName() { // If this is the entry thread, call it "main", per Trace2 convention - if (Thread.CurrentThread.ManagedThreadId == 0) + if (Thread.CurrentThread.ManagedThreadId == 1) { return "main"; } From 143ce4289001d13e2133df9b7e40133ccfe278ab Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:30:23 +0100 Subject: [PATCH 19/54] trace2: fix crash in perf format for large elapsed times BuildTimeSpan assumes an 11-character span and adjusts padding when the formatted elapsed time overflows. For values with 5+ digits before the decimal (>= 10000 seconds), the size difference exceeds the available padding budget, driving BeginPadding below zero. This causes `new string(' ', BeginPadding)` to throw an ArgumentOutOfRangeException. Since Trace2FileWriter does not catch exceptions, this crashes the credential helper when TRACE2 performance output is enabled. Fix the overflow check from `==` to `>=` so that values exceeding the full span (data + padding) zero out all padding rather than producing a negative value. Signed-off-by: Matthew John Cheetham --- src/shared/Core.Tests/Trace2MessageTests.cs | 2 ++ src/shared/Core/Trace2Message.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/Core.Tests/Trace2MessageTests.cs b/src/shared/Core.Tests/Trace2MessageTests.cs index 7e29a641f7..82c1249ca5 100644 --- a/src/shared/Core.Tests/Trace2MessageTests.cs +++ b/src/shared/Core.Tests/Trace2MessageTests.cs @@ -12,6 +12,8 @@ public class Trace2MessageTests [InlineData(26.316083, " 26.316083 ")] [InlineData(100.316083, "100.316083 ")] [InlineData(1000.316083, "1000.316083")] + [InlineData(10000.316083, "10000.316083")] + [InlineData(100000.31608, "100000.316080")] public void BuildTimeSpan_Match_Returns_Expected_String(double input, string expected) { var actual = Trace2Message.BuildTimeSpan(input); diff --git a/src/shared/Core/Trace2Message.cs b/src/shared/Core/Trace2Message.cs index 14327031ff..78eb05a203 100644 --- a/src/shared/Core/Trace2Message.cs +++ b/src/shared/Core/Trace2Message.cs @@ -151,7 +151,7 @@ private static string BuildSpan(PerformanceFormatSpan component, string data) if (double.TryParse(data, out _)) { // Remove all padding for values that take up the entire span - if (Math.Abs(sizeDifference) == paddingTotal) + if (Math.Abs(sizeDifference) >= paddingTotal) { component.BeginPadding = 0; component.EndPadding = 0; From d637224b1f8058075c452fe62c529fd1b9140938 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:33:26 +0100 Subject: [PATCH 20/54] http: use correct http.sslAutoClientCert setting name We had been incorrectly looking for the `sslAutoClientCert` Git config option under the `credential` section, rather than `http`. Note: this is a Git for Windows only option. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Settings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index af3dcf99cb..480db7ea5d 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -659,7 +659,7 @@ public bool IsCertificateVerificationEnabled } public bool AutomaticallyUseClientCertificates => - TryGetSetting(null, KnownGitCfg.Credential.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false); + TryGetSetting(null, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false); public string CustomCertificateBundlePath => TryGetPathSetting(KnownEnvars.GitSslCaInfo, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslCaInfo, out string value) ? value : null; From 782aadae7c653c544d542c78d67c65a91007b238 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:39:15 +0100 Subject: [PATCH 21/54] trace2: fix incomplete disposal of writers on cleanup ReleaseManagedResources iterates forward by index while removing elements from the same list. Each removal shifts remaining elements left, but the loop increments i, causing the next element to be skipped. As a result, only about half of the writers are disposed and removed, leaving file handles or buffers open. Fix by iterating in reverse so that removals do not shift any unvisited indices, and use RemoveAt(i) to avoid a redundant linear search. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Trace2.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/Core/Trace2.cs b/src/shared/Core/Trace2.cs index e7f048ca27..de6ca58224 100644 --- a/src/shared/Core/Trace2.cs +++ b/src/shared/Core/Trace2.cs @@ -460,11 +460,11 @@ protected override void ReleaseManagedResources() { try { - for (int i = 0; i < _writers.Count; i += 1) + for (int i = _writers.Count - 1; i >= 0; i--) { - using (var writer = _writers[i]) + using (_writers[i]) { - _writers.Remove(writer); + _writers.RemoveAt(i); } } } From a79ad388db0f33f40a93db2550da0417fb9b3f05 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 13:32:27 +0100 Subject: [PATCH 22/54] git: fix crash when reading stderr from non-redirected processes ProcessManager.CreateProcess sets RedirectStandardError=false for all processes to avoid TRACE2 deadlocks. However, GetRemotes and CreateGitException unconditionally read StandardError, which throws InvalidOperationException when stderr is not redirected. Fix GetRemotes by explicitly redirecting stderr before starting the process, since it needs to check for 'not a git repository' errors. Guard CreateGitException defensively, as it is called from various contexts where stderr may or may not be redirected. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Git.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/Core/Git.cs b/src/shared/Core/Git.cs index cef57e47d9..82588357cd 100644 --- a/src/shared/Core/Git.cs +++ b/src/shared/Core/Git.cs @@ -176,6 +176,8 @@ public IEnumerable GetRemotes() { using (var git = CreateProcess("remote -v show")) { + // Redirect stderr so we can check for 'not a git repository' errors + git.StartInfo.RedirectStandardError = true; git.Start(Trace2ProcessClass.Git); // To avoid deadlocks, always read the output stream first and then wait // TODO: don't read in all the data at once; stream it @@ -276,7 +278,9 @@ public async Task> InvokeHelperAsync(string args, ID public static GitException CreateGitException(ChildProcess git, string message, ITrace2 trace2 = null) { - var gitMessage = git.StandardError.ReadToEnd(); + var gitMessage = git.StartInfo.RedirectStandardError + ? git.StandardError.ReadToEnd() + : null; if (trace2 != null) throw new Trace2GitException(trace2, message, git.ExitCode, gitMessage); From 0c1fe0d983b6869775c631b1b513cadd2fe29a70 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 14:01:02 +0100 Subject: [PATCH 23/54] environment: check execute permission in TryLocateExecutable In f1a1ae5 (environment: manually scan $PATH on POSIX systems, 2022-05-31) the `which`-based lookup was replaced with a manual PATH scan that only checks FileExists, without verifying execute permissions. Unlike `which`, this means a non-executable file earlier in PATH can shadow a valid executable, causing process creation to fail when GCM later tries to run the located path. Add an IsExecutable check that verifies at least one execute bit is set on POSIX systems, matching the behaviour of `which`. On Windows, any existing file is considered executable. Guard the POSIX-specific File.GetUnixFileMode call with #if !NETFRAMEWORK for net472 compatibility. https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03 > PATH > [..] The list shall be searched from beginning to end, applying the > filename to each prefix, until an executable file with the specified > name and appropriate execution permissions is found Signed-off-by: Matthew John Cheetham --- src/shared/Core.Tests/EnvironmentTests.cs | 29 +++++++++++++++++++ src/shared/Core/EnvironmentBase.cs | 3 +- src/shared/Core/FileSystem.cs | 25 ++++++++++++++++ .../Objects/TestFileSystem.cs | 27 +++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/shared/Core.Tests/EnvironmentTests.cs b/src/shared/Core.Tests/EnvironmentTests.cs index d9b7cb67ce..40c685c2a0 100644 --- a/src/shared/Core.Tests/EnvironmentTests.cs +++ b/src/shared/Core.Tests/EnvironmentTests.cs @@ -94,6 +94,7 @@ public void PosixEnvironment_TryLocateExecutable_Exists_ReturnTrueAndPath() [expectedPath] = Array.Empty(), } }; + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); @@ -116,6 +117,32 @@ public void PosixEnvironment_TryLocateExecutable_ExistsMultiple_ReturnTrueAndFir ["/bin/foo"] = Array.Empty(), } }; + fs.SetExecutable(expectedPath); + fs.SetExecutable("/usr/local/bin/foo"); + fs.SetExecutable("/bin/foo"); + var envars = new Dictionary {["PATH"] = PosixPathVar}; + var env = new PosixEnvironment(fs, envars); + + bool actualResult = env.TryLocateExecutable(PosixExecName, out string actualPath); + + Assert.True(actualResult); + Assert.Equal(expectedPath, actualPath); + } + + [PosixFact] + public void PosixEnvironment_TryLocateExecutable_NotExecutable_SkipsToNextMatch() + { + string nonExecPath = "/home/john.doe/bin/foo"; + string expectedPath = "/usr/local/bin/foo"; + var fs = new TestFileSystem + { + Files = new Dictionary + { + [nonExecPath] = Array.Empty(), + [expectedPath] = Array.Empty(), + } + }; + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); @@ -142,6 +169,8 @@ public void MacOSEnvironment_TryLocateExecutable_Paths_Are_Ignored() [expectedPath] = Array.Empty(), } }; + fs.SetExecutable(pathsToIgnore.FirstOrDefault()); + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); diff --git a/src/shared/Core/EnvironmentBase.cs b/src/shared/Core/EnvironmentBase.cs index 6a39671933..39ed9dd035 100644 --- a/src/shared/Core/EnvironmentBase.cs +++ b/src/shared/Core/EnvironmentBase.cs @@ -138,7 +138,8 @@ internal virtual bool TryLocateExecutable(string program, ICollection pa { string candidatePath = Path.Combine(basePath, program); if (FileSystem.FileExists(candidatePath) && (pathsToIgnore is null || - !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase))) + !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase)) + && FileSystem.FileIsExecutable(candidatePath)) { path = candidatePath; return true; diff --git a/src/shared/Core/FileSystem.cs b/src/shared/Core/FileSystem.cs index aeacfd51d5..c23f0faa11 100644 --- a/src/shared/Core/FileSystem.cs +++ b/src/shared/Core/FileSystem.cs @@ -34,6 +34,14 @@ public interface IFileSystem /// True if a file exists, false otherwise. bool FileExists(string path); + /// + /// Check if a file has execute permissions. + /// On Windows this always returns true. On POSIX it checks for any execute bit. + /// + /// Full path to file to test. + /// True if the file is executable, false otherwise. + bool FileIsExecutable(string path); + /// /// Check if a directory exists at the specified path. /// @@ -122,6 +130,23 @@ public abstract class FileSystem : IFileSystem public bool FileExists(string path) => File.Exists(path); +#if NETFRAMEWORK + public bool FileIsExecutable(string path) => true; +#else + public bool FileIsExecutable(string path) + { + if (!PlatformUtils.IsPosix()) + return true; + +#pragma warning disable CA1416 // Platform guard via PlatformUtils.IsPosix() + var mode = File.GetUnixFileMode(path); + return (mode & (UnixFileMode.UserExecute | + UnixFileMode.GroupExecute | + UnixFileMode.OtherExecute)) != 0; +#pragma warning restore CA1416 + } +#endif + public bool DirectoryExists(string path) => Directory.Exists(path); public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); diff --git a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs index 11dff8f1f2..57a75f2b83 100644 --- a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs +++ b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs @@ -11,6 +11,7 @@ public class TestFileSystem : IFileSystem public string UserHomePath { get; set; } public string UserDataDirectoryPath { get; set; } public IDictionary Files { get; set; } = new Dictionary(); + public ISet ExecutableFiles { get; } = new HashSet(); public ISet Directories { get; set; } = new HashSet(); public string CurrentDirectory { get; set; } = Path.GetTempPath(); public bool IsCaseSensitive { get; set; } = false; @@ -36,6 +37,18 @@ bool IFileSystem.FileExists(string path) return Files.ContainsKey(path); } + bool IFileSystem.FileIsExecutable(string path) + { + if (!Files.ContainsKey(path)) + throw new FileNotFoundException("File not found", path); + + // On Windows, all files are considered executable. + if (!PlatformUtils.IsPosix()) + return true; + + return ExecutableFiles.Contains(path); + } + bool IFileSystem.DirectoryExists(string path) { return Directories.Contains(TrimSlash(path)); @@ -130,6 +143,20 @@ string[] IFileSystem.ReadAllLines(string path) #endregion + /// + /// Mark a test file as executable. File must exist in already. + /// + public void SetExecutable(string path, bool isExecutable = true) + { + if (!Files.ContainsKey(path)) + throw new FileNotFoundException("File not found", path); + + if (isExecutable) + ExecutableFiles.Add(path); + else + ExecutableFiles.Remove(path); + } + /// /// Trim trailing slashes from a path. /// From fa7b37418c11de2911bbc7005b293466c02705ca Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 14:04:58 +0100 Subject: [PATCH 24/54] windows: fix en-dash characters in installer Exec command The powershell.exe invocation in the Installer.Windows.csproj Exec task uses Unicode en dash characters (U+2013) instead of ASCII hyphens for the -NonInteractive and -ExecutionPolicy parameters. Windows PowerShell 5.1 does not recognise en dashes as parameter prefixes, so these flags are not applied correctly, which can cause the layout step to fail or run with an unexpected execution policy. Replace the en dash characters with ASCII hyphens. Signed-off-by: Matthew John Cheetham --- src/windows/Installer.Windows/Installer.Windows.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index ec678fe5f4..36f5cd5f7b 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -44,7 +44,7 @@ From d8846d1e8fcad3c689c7a56a231916c51a4c324a Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 10:58:30 +0100 Subject: [PATCH 25/54] VERSION: bump to 2.8.0 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 3646086447..0ab902011a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.3.0 +2.8.0.0 From d7778f9091a8124f07e4a5fd34d6d807446dbdf3 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:12:41 +0100 Subject: [PATCH 26/54] install: update install-from-source to use .NET 10.0 The project now targets net10.0 but the install-from-source script still referenced .NET SDK 8.0. Update all references to 10.0 and fix the version parsing to use field-based extraction instead of fixed-width character slicing, which broke for two-digit major versions. Signed-off-by: Matthew John Cheetham --- docs/install.md | 2 +- src/linux/Packaging.Linux/install-from-source.sh | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/install.md b/docs/install.md index 9fa7da4aca..86ead9557a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -210,7 +210,7 @@ the preferred install method for Linux because you can use it to install on any distribution][dotnet-supported-distributions]. You can also use this method on macOS if you so choose. -**Note:** Make sure you have installed [version 8.0 of the .NET +**Note:** Make sure you have installed [version 10.0 of the .NET SDK][dotnet-install] before attempting to run the following `dotnet tool` commands. After installing, you will also need to follow the output instructions to add the tools directory to your `PATH`. diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index 888a23597f..5a0981d6ea 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -91,7 +91,7 @@ ensure_dotnet_installed() { if [ -z "$(verify_existing_dotnet_installation)" ]; then curl -LO https://dot.net/v1/dotnet-install.sh chmod +x ./dotnet-install.sh - bash -c "./dotnet-install.sh --channel 8.0" + bash -c "./dotnet-install.sh --channel 10.0" # Since we have to run the dotnet install script with bash, dotnet isn't # added to the process PATH, so we manually add it here. @@ -103,10 +103,10 @@ ensure_dotnet_installed() { verify_existing_dotnet_installation() { # Get initial pieces of installed sdk version(s). - sdks=$(dotnet --list-sdks | cut -c 1-3) + sdks=$(dotnet --list-sdks | cut -d' ' -f1 | cut -d. -f1,2) # If we have a supported version installed, return. - supported_dotnet_versions="8.0" + supported_dotnet_versions="10.0" for v in $supported_dotnet_versions; do if [ $(echo $sdks | grep "$v") ]; then echo $sdks @@ -185,7 +185,7 @@ case "$distribution" in $sudo_cmd apt update $sudo_cmd apt install apt-transport-https -y $sudo_cmd apt update - $sudo_cmd apt install dotnet-sdk-8.0 dpkg-dev -y + $sudo_cmd apt install dotnet-sdk-10.0 dpkg-dev -y fi fi ;; From 9c6697e682cea9d2c898fdfe80ad15cf9fecf56b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:18:48 +0100 Subject: [PATCH 27/54] ci: update debian container to bookworm for .NET 10 support .NET 10 no longer supports Debian 11 (bullseye). Update the install-from-source CI matrix to use Debian 12 (bookworm). Signed-off-by: Matthew John Cheetham --- .github/workflows/validate-install-from-source.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index beecc9ae19..9da7a5bd30 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -15,7 +15,7 @@ jobs: matrix: vector: - image: ubuntu - - image: debian:bullseye + - image: debian:bookworm - image: fedora # Centos no longer officially maintains images on Docker Hub. However, # tgagor is a contributor who pushes updated images weekly, which should From 14737f4b330a36b7746e9fd3c70ee5112f144b8f Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:19:36 +0100 Subject: [PATCH 28/54] ci: run install-from-source validation on pull requests Signed-off-by: Matthew John Cheetham --- .github/workflows/validate-install-from-source.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index 9da7a5bd30..85c821eea4 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main jobs: docker: From e62235a4c64427f6dbdd060f52dc220d6cb2333b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:57:39 +0100 Subject: [PATCH 29/54] release: install .NET 8 SDK for ESRP codesigning on macOS and Linux Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index f020c676fe..fa8e1fa31e 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -301,6 +301,11 @@ extends: targetType: inline script: | echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + - task: UseDotNet@2 + displayName: 'Use .NET 8 SDK (ESRP dependency)' + inputs: + packageType: sdk + version: '8.x' - task: UseDotNet@2 displayName: 'Use .NET 10 SDK' inputs: @@ -571,6 +576,11 @@ extends: targetType: inline script: | echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + - task: UseDotNet@2 + displayName: 'Use .NET 8 SDK (ESRP dependency)' + inputs: + packageType: sdk + version: '8.x' - task: UseDotNet@2 displayName: 'Use .NET 10 SDK' inputs: From 3abada6f12da921e9b0c78f813be2c92b47b390b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 15:53:57 +0100 Subject: [PATCH 30/54] release: run nuget publish job on windows The legacy NuGetCommand@2 task (used internally by the 1ES output: nuget template) requires Mono on Linux. Recent updates to the ubuntu-x86_64-ado1es image to Ubuntu 24.04+ removed Mono availability, breaking the publish step with: "The task has failed because you are using Ubuntu 24.04 or later without mono installed." Move the nuget publish job to the win-x86_64-ado1es image so the task uses the native nuget.exe and avoids the Mono dependency entirely. The same Windows pool is already used by the dotnet_tool build job that produces these packages. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index fa8e1fa31e..a957609d89 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -872,9 +872,13 @@ extends: dependsOn: release_validation condition: and(succeeded(), eq('${{ parameters.nuget }}', true)) pool: + # Run on Windows so the underlying NuGetCommand@2 task can use the + # native nuget.exe. On Ubuntu 24.04+ the legacy NuGet task fails + # because Mono is no longer available. + # See https://aka.ms/nuget-task-mono. name: GitClientPME-1ESHostedPool-intel-pc - image: ubuntu-x86_64-ado1es - os: linux + image: win-x86_64-ado1es + os: windows variables: version: $[dependencies.release_validation.outputs['version.value']] templateContext: From 7158f0fdb0423dfa82837f33cc29f4741cbc2015 Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Fri, 22 May 2026 20:19:45 +0200 Subject: [PATCH 31/54] fix(setup): sync index of matching helper entry look for matching helper value after last empty guard/reset entry --- src/shared/Core/Application.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Application.cs b/src/shared/Core/Application.cs index ab5266460f..5b094c6cae 100644 --- a/src/shared/Core/Application.cs +++ b/src/shared/Core/Application.cs @@ -204,7 +204,7 @@ Task IConfigurableComponent.ConfigureAsync(ConfigurationTarget target) // Try to locate an existing app entry with a blank reset/clear entry immediately preceding, // and no other blank empty/clear entries following (which effectively disable us). - int appIndex = Array.FindIndex(currentValues, x => Context.FileSystem.IsSamePath(x, appPath)); + int appIndex = Array.FindLastIndex(currentValues, x => Context.FileSystem.IsSamePath(x, appPath)); int lastEmptyIndex = Array.FindLastIndex(currentValues, string.IsNullOrWhiteSpace); if (appIndex > 0 && string.IsNullOrWhiteSpace(currentValues[appIndex - 1]) && lastEmptyIndex < appIndex) { From 8421cb726d3aa5635570202525b00d9c9eafaa9e Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Fri, 22 May 2026 20:27:15 +0200 Subject: [PATCH 32/54] fix(setup): avoid adding redundant guard entry reload configuration and check for empty entry in last position --- src/shared/Core/Application.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/shared/Core/Application.cs b/src/shared/Core/Application.cs index 5b094c6cae..acdb6e27bf 100644 --- a/src/shared/Core/Application.cs +++ b/src/shared/Core/Application.cs @@ -217,9 +217,15 @@ Task IConfigurableComponent.ConfigureAsync(ConfigurationTarget target) // Clear any existing app entries in the configuration config.UnsetAll(configLevel, helperKey, Regex.Escape(appPath)); + // Reload updated helper settings (unset only clears entries in primary file, ignores includes and alternatives) + currentValues = config.GetAll(configLevel, GitConfigurationType.Raw, helperKey).ToArray(); + // Add an empty value for `credential.helper`, which has the effect of clearing any helper value // from any lower-level Git configuration, then add a second value which is the actual executable path. - config.Add(configLevel, helperKey, string.Empty); + if ((currentValues.Length == 0) || !string.IsNullOrWhiteSpace(currentValues.Last())) + { + config.Add(configLevel, helperKey, string.Empty); + } config.Add(configLevel, helperKey, appPath); } From 3524619f73f941629c49e952083f91c6b694b71c Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Fri, 22 May 2026 22:20:56 +0200 Subject: [PATCH 33/54] test(setup): add special cases for `configure` use last index for multiple existing path matches skip addition of trailing guard value for "credential.helper" --- src/shared/Core.Tests/ApplicationTests.cs | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/shared/Core.Tests/ApplicationTests.cs b/src/shared/Core.Tests/ApplicationTests.cs index f983e8d540..64beb3c6a2 100644 --- a/src/shared/Core.Tests/ApplicationTests.cs +++ b/src/shared/Core.Tests/ApplicationTests.cs @@ -178,6 +178,55 @@ public async Task Application_ConfigureAsync_EmptyAndGcmWithEmptyAfter_RemovesEx Assert.Equal(executablePath, actualValues[4]); } + [Fact] + public async Task Application_ConfigureAsync_MultiGcmWithValidEmpty_DoesNothing() + { + const string emptyHelper = ""; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; + + var context = new TestCommandContext { AppPath = executablePath }; + IConfigurableComponent application = new Application(context); + + context.Git.Configuration.Global[key] = new List + { + executablePath, emptyHelper, executablePath + }; + + await application.ConfigureAsync(ConfigurationTarget.User); + + Assert.Single(context.Git.Configuration.Global); + Assert.True(context.Git.Configuration.Global.TryGetValue(key, out var actualValues)); + Assert.Equal(3, actualValues.Count); + Assert.Equal(executablePath, actualValues[0]); + Assert.Equal(emptyHelper, actualValues[1]); + Assert.Equal(executablePath, actualValues[2]); + } + + [Fact] + public async Task Application_ConfigureAsync_EmptyOnly_AddsGcmOnly() + { + const string emptyHelper = ""; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; + + var context = new TestCommandContext { AppPath = executablePath }; + IConfigurableComponent application = new Application(context); + + context.Git.Configuration.Global[key] = new List + { + emptyHelper + }; + + await application.ConfigureAsync(ConfigurationTarget.User); + + Assert.Single(context.Git.Configuration.Global); + Assert.True(context.Git.Configuration.Global.TryGetValue(key, out var actualValues)); + Assert.Equal(2, actualValues.Count); + Assert.Equal(emptyHelper, actualValues[0]); + Assert.Equal(executablePath, actualValues[1]); + } + [Fact] public async Task Application_UnconfigureAsync_NoHelpers_DoesNothing() { From 88cc045789b89d0cfb45967ebcf192d8b8894028 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 22:00:06 +0000 Subject: [PATCH 34/54] build(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.2.0...v5.3.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 943e7c465b..fc18965e0e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 1d4488405e..5105cfae51 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x @@ -129,7 +129,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x From 5e80db1de6b31121b27627d8e2863558f5bd5ac6 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 4 Jun 2026 18:54:25 +0200 Subject: [PATCH 35/54] globals.json: specify the SDK version precisely According to https://github.com/actions/setup-dotnet/issues/739, this is required if we want to upgrade to `actions/setup-dotnet@5.3.0`. Suggested by Marc Becker. Signed-off-by: Johannes Schindelin --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 5cc6b13a63..d9483139eb 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "rollForward": "latestMajor", - "version": "8.0" + "version": "8.0.100" } } From 3a559b8eb43f356181b475ecf2e611868e2ae1ef Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 13:50:52 +0100 Subject: [PATCH 36/54] VERSION: bump to 2.9.0 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 0ab902011a..45a92322df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.0.0 +2.9.0.0 From ea84a2534c1721c052a42e81c12e6c7f0a504abb Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 17 Jun 2026 17:17:00 +0100 Subject: [PATCH 37/54] oauth: support non-query response modes The authorization code flow only handled the default 'query' response mode, where the loopback browser reads the response from the request query string and returns a URI for the client to parse. The 'fragment' and 'form_post' modes deliver the response over channels a URI cannot represent - the fragment is never transmitted to the server, and form_post arrives as a urlencoded POST body - so hosts that mandate those modes could not be used. Have the browser return the parsed response parameters regardless of transport and tell it which mode to expect. The system browser reads the POST body for form_post, and for fragment serves a small page that re-submits the parameters as a form POST to the loopback redirect - keeping the authorization code out of the URL, browser history, and server logs. The client sends 'response_mode' only when it is not the default, so existing query-mode requests are unchanged. Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- .../Cloud/BitbucketOAuth2ClientTest.cs | 9 +- .../DataCenter/BitbucketOAuth2ClientTest.cs | 9 +- .../Authentication/OAuth2ClientTests.cs | 83 ++++++++++++ .../Authentication/OAuth2ResponseModeTests.cs | 42 ++++++ .../OAuth2SystemWebBrowserTests.cs | 16 +++ .../Authentication/OAuth/IOAuth2WebBrowser.cs | 14 +- .../Core/Authentication/OAuth/OAuth2Client.cs | 28 ++-- .../Authentication/OAuth/OAuth2Constants.cs | 4 + .../OAuth/OAuth2ResponseMode.cs | 90 +++++++++++++ .../OAuth/OAuth2SystemWebBrowser.cs | 122 ++++++++++++++---- src/shared/Core/Constants.cs | 4 + .../Objects/TestOAuth2WebBrowser.cs | 6 +- 12 files changed, 383 insertions(+), 44 deletions(-) create mode 100644 src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs create mode 100644 src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs diff --git a/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs b/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs index 1a6866fb63..e57caf6931 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs @@ -36,7 +36,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode() Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(finalCallbackUri, null, client.Scopes); + MockGetAuthenticationResponseAsync(finalCallbackUri, null, client.Scopes); MockCodeGenerator(); @@ -56,7 +56,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_RespectsClient Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(finalCallbackUri, clientId, client.Scopes); + MockGetAuthenticationResponseAsync(finalCallbackUri, clientId, client.Scopes); MockCodeGenerator(); @@ -115,7 +115,7 @@ private void MockCodeGenerator() codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge); } - private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) + private void MockGetAuthenticationResponseAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) { var authorizationUri = new UriBuilder(CloudConstants.OAuth2AuthorizationEndpoint) { @@ -128,7 +128,8 @@ private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrid + "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToLower() }.Uri; - browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, rootCallbackUri, ct)).Returns(Task.FromResult(finalCallbackUri)); + browser.Setup(b => b.GetAuthenticationResponseAsync(authorizationUri, rootCallbackUri, OAuth2ResponseMode.Default, ct)) + .Returns(Task.FromResult(finalCallbackUri.GetQueryParameters())); } private Uri MockFinalCallbackUri() diff --git a/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs b/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs index e2e7225db3..5931a6a0c7 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs @@ -37,7 +37,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode() var client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes); + MockGetAuthenticationResponseAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes); MockCodeGenerator(); @@ -58,7 +58,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode_Wh var client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes); + MockGetAuthenticationResponseAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes); MockCodeGenerator(); @@ -90,7 +90,7 @@ private void MockCodeGenerator() codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge); } - private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) + private void MockGetAuthenticationResponseAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) { var authorizationUri = new UriBuilder(url + "/rest/oauth2/latest/authorize") { @@ -103,7 +103,8 @@ private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri fin + "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToUpper() }.Uri; - browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct)).Returns(Task.FromResult(finalCallbackUri)); + browser.Setup(b => b.GetAuthenticationResponseAsync(authorizationUri, redirectUri, OAuth2ResponseMode.Default, ct)) + .Returns(Task.FromResult(finalCallbackUri.GetQueryParameters())); } private Uri MockFinalCallbackUri(Uri redirectUri) diff --git a/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs b/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs index be660b99bb..1ec3eae251 100644 --- a/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs +++ b/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs @@ -174,6 +174,89 @@ await Assert.ThrowsAsync(() => client.GetAuthorizationCodeAsync(expectedScopes, browser, extraParams, CancellationToken.None)); } + [Theory] + [InlineData(OAuth2ResponseMode.Query, "query")] + [InlineData(OAuth2ResponseMode.Fragment, "fragment")] + [InlineData(OAuth2ResponseMode.FormPost, "form_post")] + public async Task OAuth2Client_GetAuthorizationCodeAsync_NonDefaultResponseMode_SendsResponseModeParameter( + OAuth2ResponseMode responseMode, string expectedValue) + { + const string expectedAuthCode = "68c39cbd8d"; + + var baseUri = new Uri("https://example.com"); + OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri); + + var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true}; + + string[] expectedScopes = {"read", "write", "delete"}; + + OAuth2Application app = CreateTestApplication(); + + var server = new TestOAuth2Server(endpoints); + server.RegisterApplication(app); + server.Bind(httpHandler); + server.TokenGenerator.AuthCodes.Add(expectedAuthCode); + + server.AuthorizationEndpointInvoked += (_, request) => + { + IDictionary actualParams = request.RequestUri.GetQueryParameters(); + Assert.True(actualParams.TryGetValue( + OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter, out string actualMode)); + Assert.Equal(expectedValue, actualMode); + }; + + IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler); + + var trace2 = new NullTrace2(); + OAuth2Client client = new OAuth2Client( + new HttpClient(httpHandler), endpoints, TestClientId, trace2, + TestRedirectUri, TestClientSecret, responseMode: responseMode); + + OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync( + expectedScopes, browser, null, CancellationToken.None); + + Assert.Equal(expectedAuthCode, result.Code); + } + + [Fact] + public async Task OAuth2Client_GetAuthorizationCodeAsync_DefaultResponseMode_OmitsResponseModeParameter() + { + const string expectedAuthCode = "68c39cbd8d"; + + var baseUri = new Uri("https://example.com"); + OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri); + + var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true}; + + string[] expectedScopes = {"read", "write", "delete"}; + + OAuth2Application app = CreateTestApplication(); + + var server = new TestOAuth2Server(endpoints); + server.RegisterApplication(app); + server.Bind(httpHandler); + server.TokenGenerator.AuthCodes.Add(expectedAuthCode); + + server.AuthorizationEndpointInvoked += (_, request) => + { + IDictionary actualParams = request.RequestUri.GetQueryParameters(); + Assert.False(actualParams.ContainsKey( + OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter)); + }; + + IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler); + + var trace2 = new NullTrace2(); + OAuth2Client client = new OAuth2Client( + new HttpClient(httpHandler), endpoints, TestClientId, trace2, + TestRedirectUri, TestClientSecret, responseMode: OAuth2ResponseMode.Default); + + OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync( + expectedScopes, browser, null, CancellationToken.None); + + Assert.Equal(expectedAuthCode, result.Code); + } + [Fact] public async Task OAuth2Client_GetDeviceCodeAsync() { diff --git a/src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs b/src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs new file mode 100644 index 0000000000..a52bf23508 --- /dev/null +++ b/src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs @@ -0,0 +1,42 @@ +using GitCredentialManager.Authentication.OAuth; +using Xunit; + +namespace GitCredentialManager.Tests.Authentication; + +public class OAuth2ResponseModeTests +{ + [Theory] + [InlineData(OAuth2ResponseMode.Default, null)] + [InlineData(OAuth2ResponseMode.Query, "query")] + [InlineData(OAuth2ResponseMode.Fragment, "fragment")] + [InlineData(OAuth2ResponseMode.FormPost, "form_post")] + public void OAuth2ResponseMode_GetParameterValue(OAuth2ResponseMode mode, string expected) + { + Assert.Equal(expected, mode.GetParameterValue()); + } + + [Theory] + [InlineData("query", OAuth2ResponseMode.Query)] + [InlineData("Query", OAuth2ResponseMode.Query)] + [InlineData("fragment", OAuth2ResponseMode.Fragment)] + [InlineData("FRAGMENT", OAuth2ResponseMode.Fragment)] + [InlineData("form_post", OAuth2ResponseMode.FormPost)] + [InlineData("FORM_POST", OAuth2ResponseMode.FormPost)] + [InlineData("formpost", OAuth2ResponseMode.FormPost)] + public void OAuth2ResponseMode_TryParse_Valid(string value, OAuth2ResponseMode expected) + { + Assert.True(OAuth2ResponseModeExtensions.TryParse(value, out OAuth2ResponseMode actual)); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("web_message")] + [InlineData("unknown")] + public void OAuth2ResponseMode_TryParse_Invalid_ReturnsFalse(string value) + { + Assert.False(OAuth2ResponseModeExtensions.TryParse(value, out _)); + } +} diff --git a/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs b/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs index cea6abe17f..9274845a2f 100644 --- a/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs +++ b/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs @@ -63,4 +63,20 @@ public void OAuth2SystemWebBrowser_UpdateRedirectUri_AnyPort(string input) ); Assert.False(actualUri.IsDefaultPort); } + + [Theory] + [InlineData("application/x-www-form-urlencoded", true)] + [InlineData("application/x-www-form-urlencoded; charset=utf-8", true)] + [InlineData("application/x-www-form-urlencoded;charset=UTF-8", true)] + [InlineData("APPLICATION/X-WWW-FORM-URLENCODED", true)] + [InlineData(" application/x-www-form-urlencoded ; charset=utf-8 ", true)] + [InlineData("application/json", false)] + [InlineData("text/plain; charset=utf-8", false)] + [InlineData("multipart/form-data; boundary=----abc", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void OAuth2SystemWebBrowser_IsFormUrlEncoded(string contentType, bool expected) + { + Assert.Equal(expected, OAuth2SystemWebBrowser.IsFormUrlEncoded(contentType)); + } } diff --git a/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs b/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs index a9dbdf519a..72a30de613 100644 --- a/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs @@ -1,5 +1,5 @@ using System; -using System.Net; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -9,6 +9,16 @@ public interface IOAuth2WebBrowser { Uri UpdateRedirectUri(Uri uri); - Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct); + /// + /// Drive the user agent through the authorization request and intercept the + /// authorization response delivered to the redirect URI. + /// + /// Authorization request URI to open in the user agent. + /// Redirect URI to intercept the response on. + /// Mechanism the authorization server uses to deliver the response. + /// Token to cancel the operation. + /// The authorization response parameters. + Task> GetAuthenticationResponseAsync( + Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct); } } diff --git a/src/shared/Core/Authentication/OAuth/OAuth2Client.cs b/src/shared/Core/Authentication/OAuth/OAuth2Client.cs index 27834d2aaf..75120522cc 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2Client.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2Client.cs @@ -73,6 +73,7 @@ public class OAuth2Client : IOAuth2Client private readonly ITrace2 _trace2; private readonly string _clientSecret; private readonly bool _addAuthHeader; + private readonly OAuth2ResponseMode _responseMode; private IOAuth2CodeGenerator _codeGenerator; @@ -82,7 +83,8 @@ public OAuth2Client(HttpClient httpClient, ITrace2 trace2, Uri redirectUri = null, string clientSecret = null, - bool addAuthHeader = true) + bool addAuthHeader = true, + OAuth2ResponseMode responseMode = OAuth2ResponseMode.Default) { _httpClient = httpClient; _endpoints = endpoints; @@ -91,6 +93,7 @@ public OAuth2Client(HttpClient httpClient, _redirectUri = redirectUri; _clientSecret = clientSecret; _addAuthHeader = addAuthHeader; + _responseMode = responseMode; } public IOAuth2CodeGenerator CodeGenerator @@ -119,6 +122,13 @@ public async Task GetAuthorizationCodeAsync(IEnum [OAuth2Constants.AuthorizationEndpoint.PkceChallengeParameter] = codeChallenge }; + // Only send the parameter when requesting a non-default mode to keep the request unchanged otherwise. + if (_responseMode != OAuth2ResponseMode.Default) + { + queryParams[OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter] = + _responseMode.GetParameterValue(); + } + if (extraQueryParams?.Count > 0) { foreach (var kvp in extraQueryParams) @@ -157,25 +167,27 @@ public async Task GetAuthorizationCodeAsync(IEnum Uri authorizationUri = authorizationUriBuilder.Uri; - // Open the browser at the request URI to start the authorization code grant flow. - Uri finalUri = await browser.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct); + // Open the browser at the request URI to start the authorization code grant flow, and + // intercept the response parameters delivered to the redirect URI. + IDictionary responseParams = + await browser.GetAuthenticationResponseAsync(authorizationUri, redirectUri, _responseMode, ct); // Check for errors serious enough we should terminate the flow, such as if the state value returned does // not match the one we passed. This indicates a badly implemented Authorization Server, or worse, some // form of failed MITM or replay attack. - IDictionary redirectQueryParams = finalUri.GetQueryParameters(); - if (!redirectQueryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.StateParameter, out string replyState)) + if (!responseParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.StateParameter, out string replyState)) { - throw new Trace2OAuth2Exception(_trace2, $"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response."); + throw new Trace2OAuth2Exception(_trace2, + $"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response."); } if (!StringComparer.Ordinal.Equals(state, replyState)) { throw new Trace2OAuth2Exception(_trace2, - $"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response."); + $"Invalid '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response; does not match the request."); } // We expect to have the auth code in the response otherwise terminate the flow (we failed authentication for some reason) - if (!redirectQueryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter, out string authCode)) + if (!responseParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter, out string authCode)) { throw new Trace2OAuth2Exception(_trace2, $"Missing '{OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter}' in response."); diff --git a/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs b/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs index 0b96a60476..a1c0ca90a8 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs @@ -14,6 +14,10 @@ public static class AuthorizationEndpoint public const string StateParameter = "state"; public const string AuthorizationCodeResponseType = "code"; public const string ResponseTypeParameter = "response_type"; + public const string ResponseModeParameter = "response_mode"; + public const string QueryResponseMode = "query"; + public const string FragmentResponseMode = "fragment"; + public const string FormPostResponseMode = "form_post"; public const string PkceChallengeParameter = "code_challenge"; public const string PkceChallengeMethodParameter = "code_challenge_method"; public const string PkceChallengeMethodPlain = "plain"; diff --git a/src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs b/src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs new file mode 100644 index 0000000000..2f5ef0f13f --- /dev/null +++ b/src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs @@ -0,0 +1,90 @@ +using System; + +namespace GitCredentialManager.Authentication.OAuth; + +/// +/// The mechanism the authorization server uses to return authorization response +/// parameters to the redirect URI. +/// +public enum OAuth2ResponseMode +{ + /// + /// Use the default response mode as determined by the authorization server. + /// + Default = 0, + + /// + /// Parameters are encoded in the query component of the redirect URI. + /// + Query, + + /// + /// Parameters are encoded in the fragment component of the redirect URI. + /// + Fragment, + + /// + /// Parameters are returned as an HTML form that is auto-submitted as an + /// application/x-www-form-urlencoded POST to the redirect URI, as + /// described by the OAuth 2.0 Form Post Response Mode specification. + /// + FormPost, +} + +public static class OAuth2ResponseModeExtensions +{ + /// + /// Get the wire value for the response_mode authorization request parameter. + /// + public static string GetParameterValue(this OAuth2ResponseMode mode) + { + switch (mode) + { + case OAuth2ResponseMode.Default: + return null; + case OAuth2ResponseMode.Query: + return OAuth2Constants.AuthorizationEndpoint.QueryResponseMode; + case OAuth2ResponseMode.Fragment: + return OAuth2Constants.AuthorizationEndpoint.FragmentResponseMode; + case OAuth2ResponseMode.FormPost: + return OAuth2Constants.AuthorizationEndpoint.FormPostResponseMode; + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown OAuth2 response mode."); + } + } + + /// + /// Try to parse a response_mode wire value into an . + /// + public static bool TryParse(string value, out OAuth2ResponseMode mode) + { + mode = OAuth2ResponseMode.Default; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (StringComparer.OrdinalIgnoreCase.Equals(value, OAuth2Constants.AuthorizationEndpoint.QueryResponseMode)) + { + mode = OAuth2ResponseMode.Query; + return true; + } + + if (StringComparer.OrdinalIgnoreCase.Equals(value, OAuth2Constants.AuthorizationEndpoint.FragmentResponseMode)) + { + mode = OAuth2ResponseMode.Fragment; + return true; + } + + // Accept both "form_post" (wire value) and "formpost" for convenience. + if (StringComparer.OrdinalIgnoreCase.Equals(value, OAuth2Constants.AuthorizationEndpoint.FormPostResponseMode) || + StringComparer.OrdinalIgnoreCase.Equals(value, "formpost")) + { + mode = OAuth2ResponseMode.FormPost; + return true; + } + + return false; + } +} diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs index 05843f9df2..4f55072a47 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Sockets; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -36,6 +38,34 @@ public class OAuth2WebBrowserOptions public class OAuth2SystemWebBrowser : IOAuth2WebBrowser { + // Served during the fragment response flow. The authorization parameters live in the + // URI fragment, which user agents do not transmit to the server, so we reissue them as + // a form POST to the redirect URI - keeping them out of the URL (and thus out of + // browser history and server logs) and letting the listener read them from the body. + private const string FragmentFormPostHtml = @" +Authenticating... +
"; + private readonly ISessionManager _sessionManager; private readonly OAuth2WebBrowserOptions _options; @@ -65,26 +95,28 @@ public Uri UpdateRedirectUri(Uri uri) return uri; } - public async Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct) + public async Task> GetAuthenticationResponseAsync( + Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct) { if (!redirectUri.IsLoopback) { throw new ArgumentException("Only localhost is supported as a redirect URI.", nameof(redirectUri)); } - Task interceptTask = InterceptRequestsAsync(redirectUri, ct); + Task> interceptTask = InterceptRequestsAsync(redirectUri, responseMode, ct); _sessionManager.OpenBrowser(authorizationUri); return await interceptTask; } - private async Task InterceptRequestsAsync(Uri listenUri, CancellationToken ct) + private async Task> InterceptRequestsAsync( + Uri listenUri, OAuth2ResponseMode responseMode, CancellationToken ct) { // Create a TaskCompletionSource which completes when we're asked to cancel. - // We can then await the this task together with other tasks that don't take a + // We can then await this task together with other tasks that don't take a // CancellationToken and exit the method quickly when cancelled. - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource>(); ct.Register(() => tcs.SetCanceled()); // Prefixes must end with a '/' @@ -99,25 +131,40 @@ private async Task InterceptRequestsAsync(Uri listenUri, CancellationToken try { - Task contextTask = listener.GetContextAsync(); - Task cancelTask = tcs.Task; + while (true) + { + Task contextTask = listener.GetContextAsync(); + Task> cancelTask = tcs.Task; - Task completedTask = await Task.WhenAny(contextTask, tcs.Task); + Task completedTask = await Task.WhenAny(contextTask, cancelTask); - // Check if we 'completed' the context task or the cancellation task - if (completedTask == cancelTask) - { - // We were cancelled! - return await cancelTask; - } + // Check if we 'completed' the context task or the cancellation task + if (completedTask == cancelTask) + { + // We were cancelled! + return await cancelTask; + } + + // We intercepted a request! + HttpListenerContext context = await contextTask; - // We intercepted a request! - HttpListenerContext context = await contextTask; + IDictionary parameters = await GetResponseParametersAsync(context.Request); - await HandleInterceptedRequestAsync(context.Request, context.Response); + // In fragment mode the authorization parameters are in the URI fragment, which + // user agents do not send to the server. The first leg is therefore a parameterless + // GET; reply with a script that reissues the parameters as a form POST so we can + // read them from the body on the next iteration. + if (responseMode == OAuth2ResponseMode.Fragment && parameters.Count == 0) + { + await context.Response.WriteResponseAsync(FragmentFormPostHtml); + context.Response.Close(); + continue; + } - // Return the final intercepted URI - return context.Request.Url; + await WriteFinalResponseAsync(context.Response, parameters); + + return parameters; + } } finally { @@ -126,14 +173,41 @@ private async Task InterceptRequestsAsync(Uri listenUri, CancellationToken } } - private async Task HandleInterceptedRequestAsync(HttpListenerRequest request, HttpListenerResponse response) + private static async Task> GetResponseParametersAsync(HttpListenerRequest request) + { + // Form post responses - and the form POST used to forward fragment responses - carry + // the authorization parameters in the urlencoded request body. + if (StringComparer.OrdinalIgnoreCase.Equals(request.HttpMethod, Constants.Http.MethodPost) && + IsFormUrlEncoded(request.ContentType)) + { + using var reader = new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8); + string body = await reader.ReadToEndAsync(); + return UriExtensions.ParseQueryString(body); + } + + // Query responses carry the parameters in the request query string. + return request.QueryString.ToDictionary(StringComparer.OrdinalIgnoreCase); + } + + internal static bool IsFormUrlEncoded(string contentType) { - IDictionary queryParams = request.QueryString.ToDictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + + // Compare only the media type, ignoring any parameters such as "; charset=utf-8". + // The media type is everything up to the first ';'. + string mediaType = contentType.Split(';')[0].Trim(); + return StringComparer.OrdinalIgnoreCase.Equals(mediaType, Constants.Http.MimeTypeFormUrlEncoded); + } + private async Task WriteFinalResponseAsync(HttpListenerResponse response, IDictionary parameters) + { // If we have an error value then the request failed and we should reply with a page containing the error information - bool hasError = queryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorCodeParameter, out string errorCode); - queryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorDescriptionParameter, out string errorDescription); - queryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorUriParameter, out string errorUri); + bool hasError = parameters.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorCodeParameter, out string errorCode); + parameters.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorDescriptionParameter, out string errorDescription); + parameters.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorUriParameter, out string errorUri); if (hasError) { string FormatError(string format) diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 6fecc2b38d..9b36d18ca9 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -145,6 +145,10 @@ public static class Http public const string WwwAuthenticateNtlmScheme = "NTLM"; public const string MimeTypeJson = "application/json"; + public const string MimeTypeFormUrlEncoded = "application/x-www-form-urlencoded"; + + public const string MethodGet = "GET"; + public const string MethodPost = "POST"; } public static class GitConfiguration diff --git a/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs b/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs index 547aaf360b..86f011cddc 100644 --- a/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs +++ b/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -20,12 +21,13 @@ public Uri UpdateRedirectUri(Uri uri) return uri; } - public async Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct) + public async Task> GetAuthenticationResponseAsync( + Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct) { using (var response = await _httpClient.SendAsync(HttpMethod.Get, authorizationUri)) { response.EnsureSuccessStatusCode(); - return response.Headers.Location; + return response.Headers.Location.GetQueryParameters(); } } } From 6cefbd9b764d2f7a2d004cde4e6b2a013ba1638d Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 08:37:18 +0100 Subject: [PATCH 38/54] generic-oauth: add response mode setting Now that the OAuth client can request non-query response modes, expose the choice to generic host configurations through a new optional setting (credential..oauthResponseMode, or the GCM_OAUTH_RESPONSE_MODE environment variable). The built-in providers target known hosts that use 'query', so the generic provider is the only place an arbitrary host's response mode needs to be configurable. The setting is optional and defaults to 'query', so existing configurations are unaffected. An unrecognised value is traced and falls back to the default rather than failing configuration outright. Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- docs/generic-oauth.md | 24 +++++++ .../Core.Tests/GenericOAuthConfigTests.cs | 71 +++++++++++++++++++ src/shared/Core/Constants.cs | 2 + src/shared/Core/GenericHostProvider.cs | 3 +- src/shared/Core/GenericOAuthConfig.cs | 18 +++++ 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/generic-oauth.md b/docs/generic-oauth.md index 92ad6dc5cc..dbf5c06fbb 100644 --- a/docs/generic-oauth.md +++ b/docs/generic-oauth.md @@ -42,6 +42,7 @@ following values in your Git configuration: - Client Secret (optional) - Redirect URL (optional, defaults to `http://127.0.0.1`) - Scopes (optional) +- Response Mode (optional, defaults to `query`) - OAuth Endpoints - Authorization Endpoint - Token Endpoint @@ -62,6 +63,7 @@ git config --global credential..oauthAuthorizeEndpoint git config --global credential..oauthTokenEndpoint git config --global credential..oauthScopes git config --global credential..oauthDeviceEndpoint +git config --global credential..oauthResponseMode ``` **Example commands:** @@ -83,6 +85,7 @@ git config --global credential..oauthDeviceEndpoint oauthScopes = "code:write profile:read" oauthDefaultUserName = "OAUTH" oauthUseClientAuthHeader = false + oauthResponseMode = "query" ``` ### Additional configuration @@ -90,6 +93,27 @@ git config --global credential..oauthDeviceEndpoint Depending on the specific implementation of OAuth with your Git host you may also need to specify additional behavior. +#### Response mode + +The response mode controls how the authorization server returns the response to +the loopback redirect URI once the user has authenticated. GCM supports the +following values: + +- `query` (default) - parameters are returned in the redirect URI query string. +- `fragment` - parameters are returned in the redirect URI fragment. +- `form_post` - parameters are returned as an auto-submitting HTML form that is + POSTed to the redirect URI, as described by the + [OAuth 2.0 Form Post Response Mode][form-post-spec] specification. + +Most hosts use the default `query` mode. Only set this if your host requires a +specific response mode: + +```shell +git config --global credential..oauthResponseMode +``` + +[form-post-spec]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html + #### Token user name If your Git host requires that you specify a username to use with OAuth tokens diff --git a/src/shared/Core.Tests/GenericOAuthConfigTests.cs b/src/shared/Core.Tests/GenericOAuthConfigTests.cs index b05ae2e8b3..cd1f1573fe 100644 --- a/src/shared/Core.Tests/GenericOAuthConfigTests.cs +++ b/src/shared/Core.Tests/GenericOAuthConfigTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using GitCredentialManager.Authentication.OAuth; using GitCredentialManager.Tests.Objects; using Xunit; @@ -99,5 +100,75 @@ public void GenericOAuthConfig_TryGet_Gitea() Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint); Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint); } + + [Theory] + [InlineData("query", OAuth2ResponseMode.Query)] + [InlineData("fragment", OAuth2ResponseMode.Fragment)] + [InlineData("form_post", OAuth2ResponseMode.FormPost)] + [InlineData("FORM_POST", OAuth2ResponseMode.FormPost)] + public void GenericOAuthConfig_TryGet_ParsesResponseMode(string value, OAuth2ResponseMode expected) + { + bool result = TryGetWithResponseMode(value, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(expected, config.ResponseMode); + } + + [Fact] + public void GenericOAuthConfig_TryGet_InvalidResponseMode_FallsBackToDefault() + { + bool result = TryGetWithResponseMode("bogus", out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(OAuth2ResponseMode.Default, config.ResponseMode); + } + + [Fact] + public void GenericOAuthConfig_TryGet_ResponseModeUnset_UsesDefault() + { + bool result = TryGetWithResponseMode(null, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(OAuth2ResponseMode.Default, config.ResponseMode); + } + + private static bool TryGetWithResponseMode(string responseMode, out GenericOAuthConfig config) + { + const string protocol = "https"; + const string host = "example.com"; + var remoteUri = new Uri($"{protocol}://{host}"); + + string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}"; + + var trace = new NullTrace(); + var gitConfig = new TestGitConfiguration + { + Global = + { + [GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { "client-id" }, + [GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { "/oauth/authorize" }, + [GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { "/oauth/token" }, + } + }; + + if (responseMode != null) + { + gitConfig.Global[GetKey(Constants.GitConfiguration.Credential.OAuthResponseMode)] = new[] { responseMode }; + } + + var settings = new TestSettings + { + GitConfiguration = gitConfig, + RemoteUri = remoteUri + }; + + var input = new InputArguments(new Dictionary + { + {"protocol", protocol}, + {"host", host}, + }); + + return GenericOAuthConfig.TryGet(trace, settings, input, out config); + } } } diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 9b36d18ca9..d906d3a55c 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -129,6 +129,7 @@ public static class EnvironmentVariables public const string OAuthDeviceEndpoint = "GCM_OAUTH_DEVICE_ENDPOINT"; public const string OAuthClientAuthHeader = "GCM_OAUTH_USE_CLIENT_AUTH_HEADER"; public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME"; + public const string OAuthResponseMode = "GCM_OAUTH_RESPONSE_MODE"; public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES"; @@ -195,6 +196,7 @@ public static class Credential public const string OAuthDeviceEndpoint = "oauthDeviceEndpoint"; public const string OAuthClientAuthHeader = "oauthUseClientAuthHeader"; public const string OAuthDefaultUserName = "oauthDefaultUserName"; + public const string OAuthResponseMode = "oauthResponseMode"; } public static class Http diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index a66729a8a1..39af1884cd 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -275,7 +275,8 @@ private async Task GetOAuthAccessToken(Uri remoteUri, string userNa trace2, config.RedirectUri, config.ClientSecret, - config.UseAuthHeader); + config.UseAuthHeader, + config.ResponseMode); // // Prepend "refresh_token" to the hostname to get a (hopefully) unique service name that diff --git a/src/shared/Core/GenericOAuthConfig.cs b/src/shared/Core/GenericOAuthConfig.cs index 522d89fec3..098541babb 100644 --- a/src/shared/Core/GenericOAuthConfig.cs +++ b/src/shared/Core/GenericOAuthConfig.cs @@ -134,6 +134,23 @@ public static bool TryGet(ITrace trace, ISettings settings, InputArguments input config.UseAuthHeader = true; } + // Response mode is optional and defaults to 'query' + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthResponseMode, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthResponseMode, + out string responseModeStr) && !string.IsNullOrWhiteSpace(responseModeStr)) + { + if (OAuth2ResponseModeExtensions.TryParse(responseModeStr, out OAuth2ResponseMode responseMode)) + { + config.ResponseMode = responseMode; + } + else + { + trace.WriteLine($"Invalid OAuth configuration - unknown response mode '{responseModeStr}'; using default"); + } + } + config.DefaultUserName = settings.TryGetSetting( Constants.EnvironmentVariables.OAuthDefaultUserName, Constants.GitConfiguration.Credential.SectionName, @@ -152,6 +169,7 @@ public static bool TryGet(ITrace trace, ISettings settings, InputArguments input public Uri RedirectUri { get; set; } public string[] Scopes { get; set; } public bool UseAuthHeader { get; set; } + public OAuth2ResponseMode ResponseMode { get; set; } public string DefaultUserName { get; set; } public bool SupportsDeviceCode => Endpoints.DeviceAuthorizationEndpoint != null; From 16e9c7fd725b83b892f436b74541774a608ce4e5 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 13:55:52 +0100 Subject: [PATCH 39/54] msal: update to latest MSAL 4.82.2 Update our MSAL library packages to the current latest release, which is 4.82.2 at time of writing. Signed-off-by: Matthew John Cheetham --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d1e002d856..3e71e110a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,9 +14,9 @@ - - - + + + From 29e2f829c8b394c502cde9499479c13f4f70e59b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 15:18:43 +0100 Subject: [PATCH 40/54] msauth: resolve auth flow before selecting redirect URI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "auto" Microsoft authentication flow type was resolved lazily, deep inside the interactive-token switch via `goto case`, after the MSAL public client application — and thus its redirect URI — had already been built. That entangled flow selection with app creation and made the effective flow hard to follow in traces. Resolve the flow up front in GetFlowType() instead, and drop the Auto pseudo-value from the enum so the method always returns a concrete flow (embedded web view, system web view, or device code). The resolved flow is traced before authentication starts. Knowing the flow up front also lets us choose the redirect URI. Only the system web view needs a real loopback redirect URI registered with the application; the other paths work with MSAL's default native-client redirect URI — "https://login.microsoftonline.com/common/oauth2/nativeclient" on .NET Framework, "http://localhost" on .NET Core. Forward the caller-provided redirect URI only when the system web view might be used and let MSAL supply the default otherwise via WithDefaultRedirectUri(). The Microsoft authentication diagnostic no longer calls GetFlowType() (which now needs a redirect URI and eagerly resolves auto); it reports the raw credential.msAuthFlow override instead. The now-unused IPublicClientApplication argument is dropped from the system web view capability checks. Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- .../Authentication/MicrosoftAuthentication.cs | 88 +++++++++++++------ .../MicrosoftAuthenticationDiagnostic.cs | 10 ++- 2 files changed, 71 insertions(+), 27 deletions(-) diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5d65fa9823..86b0feff08 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -30,7 +30,7 @@ public interface IMicrosoftAuthentication /// /// Azure authority. /// Client ID. - /// Redirect URI for the client. + /// Redirect URI for the client. Use null for the default redirect URI. /// Set of scopes to request. /// Optional user name for an existing account. /// Use MSA-Passthrough behavior when authenticating. @@ -116,10 +116,9 @@ public interface IMicrosoftAuthenticationResult public enum MicrosoftAuthenticationFlowType { - Auto = 0, - EmbeddedWebView = 1, - SystemWebView = 2, - DeviceCode = 3 + EmbeddedWebView, + SystemWebView, + DeviceCode } public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthentication @@ -152,6 +151,27 @@ public async Task GetTokenForUserAsync( Context.Trace.WriteLine("MSA passthrough is enabled."); } + // Check if the user has specified a particular type of authentication flow + MicrosoftAuthenticationFlowType flowType = GetFlowType(redirectUri); + Context.Trace.WriteLine($"Flow type is: '{flowType}'."); + + // If we are going to use anything *other than* the system webview, we ignore + // the provided redirect URI and set it to the default for a native client. + // The broker is used above all else, if enabled, but that has a fallback to + // the system browser if there is a problem. + // We must continue to pass through the provided redirect URI if we're going to + // try the system webview, as the system webview requires a real loopback redirect + // URI that is registered with the application. + if (!useBroker && flowType != MicrosoftAuthenticationFlowType.SystemWebView) + { + Context.Trace.WriteLine("Using default redirect URI."); + redirectUri = null; // null to signal the default redirect URI + } + else + { + Context.Trace.WriteLine($"Redirect URI is '{redirectUri}'."); + } + try { // Create the public client application for authentication @@ -214,27 +234,17 @@ public async Task GetTokenForUserAsync( Context.Trace.WriteLine("Performing interactive auth with broker..."); result = await app.AcquireTokenInteractive(scopes) .WithPrompt(Prompt.SelectAccount) - // We must configure the system webview as a fallback + // We must configure the system webview as a fallback in case + // the broker is not available on this system. .WithSystemWebViewOptions(GetSystemWebViewOptions()) .ExecuteAsync(); } } else { - // Check for a user flow preference if they've specified one - MicrosoftAuthenticationFlowType flowType = GetFlowType(); + // Respect the user's flow preference switch (flowType) { - case MicrosoftAuthenticationFlowType.Auto: - if (CanUseEmbeddedWebView()) - goto case MicrosoftAuthenticationFlowType.EmbeddedWebView; - - if (CanUseSystemWebView(app, redirectUri)) - goto case MicrosoftAuthenticationFlowType.SystemWebView; - - // Fall back to device code flow - goto case MicrosoftAuthenticationFlowType.DeviceCode; - case MicrosoftAuthenticationFlowType.EmbeddedWebView: Context.Trace.WriteLine("Performing interactive auth with embedded web view..."); EnsureCanUseEmbeddedWebView(); @@ -247,7 +257,7 @@ public async Task GetTokenForUserAsync( case MicrosoftAuthenticationFlowType.SystemWebView: Context.Trace.WriteLine("Performing interactive auth with system web view..."); - EnsureCanUseSystemWebView(app, redirectUri); + EnsureCanUseSystemWebView(redirectUri); result = await app.AcquireTokenInteractive(scopes) .WithPrompt(Prompt.SelectAccount) .WithSystemWebViewOptions(GetSystemWebViewOptions()) @@ -263,7 +273,7 @@ public async Task GetTokenForUserAsync( break; default: - goto case MicrosoftAuthenticationFlowType.Auto; + goto case MicrosoftAuthenticationFlowType.DeviceCode; // safe default } } } @@ -457,7 +467,7 @@ await AvaloniaUi.ShowViewAsync( } } - internal MicrosoftAuthenticationFlowType GetFlowType() + internal MicrosoftAuthenticationFlowType GetFlowType(Uri redirectUri) { if (Context.Settings.TryGetSetting( Constants.EnvironmentVariables.MsAuthFlow, @@ -469,7 +479,7 @@ internal MicrosoftAuthenticationFlowType GetFlowType() switch (valueStr.ToLowerInvariant()) { case "auto": - return MicrosoftAuthenticationFlowType.Auto; + return Auto(); case "embedded": return MicrosoftAuthenticationFlowType.EmbeddedWebView; case "system": @@ -483,7 +493,21 @@ internal MicrosoftAuthenticationFlowType GetFlowType() Context.Streams.Error.WriteLine($"warning: unknown Microsoft Authentication flow type '{valueStr}'; using 'auto'"); } - return MicrosoftAuthenticationFlowType.Auto; + return Auto(); + + // Resolve the 'auto' flow type based on the redirect URI and platform capabilities + MicrosoftAuthenticationFlowType Auto() + { + // Prefer embedded webview + if (CanUseEmbeddedWebView()) + return MicrosoftAuthenticationFlowType.EmbeddedWebView; + + if (CanUseSystemWebView(redirectUri)) + return MicrosoftAuthenticationFlowType.SystemWebView; + + // Fall back to device code flow + return MicrosoftAuthenticationFlowType.DeviceCode; + } } /// @@ -550,9 +574,21 @@ private async Task CreatePublicClientApplicationAsync( var appBuilder = PublicClientApplicationBuilder.Create(clientId) .WithAuthority(authority) - .WithRedirectUri(redirectUri.ToString()) .WithHttpClientFactory(httpFactoryAdaptor); + // Use the default redirect URI if one is not provided + if (redirectUri is null) + { + // Uses "https://login.microsoftonline.com/common/oauth2/nativeclient" on .NET Framework + // but "http://localhost" on .NET Core. This is because there is no embedded webview support + // in .NET Core and thus the system webview is the only option. + appBuilder.WithDefaultRedirectUri(); + } + else + { + appBuilder.WithRedirectUri(redirectUri.ToString()); + } + // Listen to MSAL logs if GCM_TRACE_MSAUTH is set if (Context.Settings.IsMsalTracingEnabled) { @@ -988,7 +1024,7 @@ private void EnsureCanUseEmbeddedWebView() #endif } - private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) + private bool CanUseSystemWebView(Uri redirectUri) { // // MSAL requires the application redirect URI is a loopback address to use the System WebView @@ -1000,7 +1036,7 @@ private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) return Context.SessionManager.IsWebBrowserAvailable && redirectUri.IsLoopback; } - private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) + private void EnsureCanUseSystemWebView(Uri redirectUri) { if (!Context.SessionManager.IsWebBrowserAvailable) { diff --git a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs index e4dba08224..ad64b7f810 100644 --- a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs +++ b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs @@ -17,7 +17,15 @@ protected override async Task RunInternalAsync(StringBuilder log, IList Date: Fri, 19 Jun 2026 09:21:27 +0000 Subject: [PATCH 41/54] build(deps): bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- .github/workflows/lint-docs.yml | 4 ++-- .github/workflows/validate-install-from-source.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fc18965e0e..19cd069f84 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,7 +22,7 @@ jobs: language: [ 'csharp' ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 5105cfae51..08d9274fba 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -25,7 +25,7 @@ jobs: os: windows-11-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 @@ -82,7 +82,7 @@ jobs: runtime: [ linux-x64, linux-arm64, linux-arm ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 @@ -126,7 +126,7 @@ jobs: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index bfbd2bbfaf..ff64b9bcd2 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -18,7 +18,7 @@ jobs: name: Lint markdown files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 with: @@ -30,7 +30,7 @@ jobs: name: Check for broken links runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run link checker # For any troubleshooting, see: diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index 85c821eea4..dca6f56b46 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -45,7 +45,7 @@ jobs: GNUPGHOME=/root/.gnupg tdnf install tar -y # needed for `actions/checkout` fi - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - run: | sh "${GITHUB_WORKSPACE}/src/linux/Packaging.Linux/install-from-source.sh" -y From 7bb63e8ab7cd20f07c6ff08d6c63782893fcc739 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:32:18 +0000 Subject: [PATCH 42/54] build(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 19cd069f84..72d5418879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 08d9274fba..9997aa42ae 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -129,7 +129,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x From 69fc517083e7098058fff8ed820dac7b22ef4d93 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 29 Jun 2026 10:15:49 +0100 Subject: [PATCH 43/54] docs: fix broken links identified by linting The link to the Windows Credential Manager docs was broken - it used to point at: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 ..but this now resolves instead to: https://support.microsoft.com/en-US/Windows/Security/credential-manager-in-windows ..so let's use that URL directly. Signed-off-by: Matthew John Cheetham --- docs/credstores.md | 2 +- docs/github-apideprecation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/credstores.md b/docs/credstores.md index ca76f56926..4d54059e60 100644 --- a/docs/credstores.md +++ b/docs/credstores.md @@ -277,7 +277,7 @@ Note that you'll want to ensure that another credential helper is placed before GCM in the `credential.helper` Git configuration or else you will be prompted to enter your credentials every time you interact with a remote repository. -[access-windows-credential-manager]: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 +[access-windows-credential-manager]: https://support.microsoft.com/en-US/Windows/Security/credential-manager-in-windows [aws-cloudshell]: https://aws.amazon.com/cloudshell/ [azure-cloudshell]: https://docs.microsoft.com/azure/cloud-shell/overview [cmdkey]: https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/cmdkey diff --git a/docs/github-apideprecation.md b/docs/github-apideprecation.md index 6a54a7a401..7075085d29 100644 --- a/docs/github-apideprecation.md +++ b/docs/github-apideprecation.md @@ -143,6 +143,6 @@ the new token-based authentication requirements **DO NOT** apply to GHES: [windows-cli-save-pat-image]: img/windows-cli-save-pat.png [vs-2019]: https://docs.microsoft.com/en-us/visualstudio/install/update-visual-studio?view=vs-2019 [vs-2017]: https://docs.microsoft.com/en-us/visualstudio/install/update-visual-studio?view=vs-2017 -[windows-credential-manager]: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 +[windows-credential-manager]: https://support.microsoft.com/en-US/Windows/Security/credential-manager-in-windows [windows-gui-add-pat-image]: img/windows-gui-add-pat.png [windows-gui-credentials-image]: img/windows-gui-credentials.png From dcb5fd1cf76fda819187da60a38d931df91e48ef Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 7 Jul 2026 09:52:10 +0200 Subject: [PATCH 44/54] linux: use the appropriate PGP key to sign the Debian packages We have been using an inappropriate key for our Debian package signing; let's use a more appropriate one. Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index a957609d89..6042eba2d3 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -639,7 +639,7 @@ extends: inlineOperation: | [ { - "KeyCode": "CP-453387-Pgp", + "KeyCode": "CP-500207-Pgp", "OperationCode": "LinuxSign", "ToolName": "sign", "ToolVersion": "1.0", From 73696fa693c07d679ef17e56eeeae59eacf99106 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 6 Jul 2026 18:48:36 +0100 Subject: [PATCH 45/54] browser: open AbsoluteUri to avoid double-escaping GCM launches the system browser for interactive OAuth by handing the authorization URL to the OS "shell execute" handler. On macOS that is /usr/bin/open, which validates the URL and, on finding any character that is not legal in a fully percent-encoded URL, re-encodes the whole query string. That step double-escapes parameters we had already encoded -- redirect_uri=http%3A%2F%2F... becomes redirect_uri=http%253A%252F%252F... -- and the authorization server rejects the redirect. Windows ShellExecuteEx forwards the string verbatim, so only macOS is affected. The trigger was a raw space in the query. Uri.ToString() is a display form that unescapes %20 back to a literal space (while leaving %2F alone), so building the launch string that way reintroduced spaces, most easily via the space-delimited scope parameter. This surfaced after MSAL began encoding spaces[1] as %20 rather than +; a literal + is left untouched by ToString(), which had masked the problem. Uri.AbsoluteUri keeps the query fully percent-encoded, so %20 stays %20 and macOS open accepts the URL unchanged. [1]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/5128 Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- src/shared/Core/ISessionManager.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/shared/Core/ISessionManager.cs b/src/shared/Core/ISessionManager.cs index 0ad0204c4b..8ee291f300 100644 --- a/src/shared/Core/ISessionManager.cs +++ b/src/shared/Core/ISessionManager.cs @@ -67,7 +67,13 @@ public void OpenBrowser(Uri uri) throw new ArgumentException("Can only open HTTP/HTTPS URIs", nameof(uri)); } - OpenBrowserInternal(uri.ToString()); + // Important! Use AbsoluteUri to ensure that the URL is properly + // escaped (e.g. spaces are converted to %20). + // The 'shell execute' handler on some operating systems (e.g. macOS) + // will try to validate the URL handed to it and if it sees any + // unescaped characters it will decide that the rest of the query + // parameters also need esacaping leading to double escaping! + OpenBrowserInternal(uri.AbsoluteUri); } protected virtual void OpenBrowserInternal(string url) From ac4391282ccc704531918e29cce45e9d7aef6910 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 7 Jul 2026 09:58:29 +0200 Subject: [PATCH 46/54] linux: adjust the instructions how to verify the signatures With the ESRP-signed packages, there is a slightly different process. Most notably, the PGP key to verify against has changed and needs to be obtained from elsewhere. Signed-off-by: Johannes Schindelin --- docs/linux-validate-gpg.md | 40 +++++++++++++++----------------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/docs/linux-validate-gpg.md b/docs/linux-validate-gpg.md index 49150c1e59..d252f4cc97 100644 --- a/docs/linux-validate-gpg.md +++ b/docs/linux-validate-gpg.md @@ -10,46 +10,42 @@ the latest Debian package and/or tarball signature. apt-get install -y curl debsig-verify # Download public key signature file -curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ -| grep -E 'browser_download_url.*gcm-public.asc' \ -| cut -d : -f 2,3 \ -| tr -d \" \ -| xargs -I 'url' curl -L -o gcm-public.asc 'url' +curl -Os https://packages.microsoft.com/keys/microsoft-2025.asc # De-armor public key signature file -gpg --output gcm-public.gpg --dearmor gcm-public.asc +gpg --output microsoft-2025.gpg --dearmor microsoft-2025.asc -# Note that the fingerprint of this key is "3C853823978B07FA", which you can +# Note that the fingerprint of this key is "EE4D7792F748182B", which you can # determine by running: -gpg --show-keys gcm-public.asc | head -n 2 | tail -n 1 | tail -c 17 +gpg --show-keys microsoft-2025.asc | head -n 2 | tail -n 1 | tail -c 17 # Copy de-armored public key to debsig keyring folder -mkdir /usr/share/debsig/keyrings/3C853823978B07FA -mv gcm-public.gpg /usr/share/debsig/keyrings/3C853823978B07FA/ +mkdir /usr/share/debsig/keyrings/EE4D7792F748182B +mv microsoft-2025.gpg /usr/share/debsig/keyrings/EE4D7792F748182B/ # Create an appropriate policy file -mkdir /etc/debsig/policies/3C853823978B07FA -cat > /etc/debsig/policies/3C853823978B07FA/generic.pol << EOL +mkdir /etc/debsig/policies/EE4D7792F748182B +cat > /etc/debsig/policies/EE4D7792F748182B/generic.pol << EOL - + - + - + EOL -# Download Debian package +# Download Debian package (substitute `x64` with `arm64` on ARM machines) curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ -| grep "browser_download_url.*deb" \ +| grep "browser_download_url.*-x64-.*deb" \ | cut -d : -f 2,3 \ | tr -d \" \ | xargs -I 'url' curl -L -o gcm.deb 'url' @@ -61,14 +57,10 @@ debsig-verify gcm.deb ## Tarball ```shell # Download the public key signature file -curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ -| grep -E 'browser_download_url.*gcm-public.asc' \ -| cut -d : -f 2,3 \ -| tr -d \" \ -| xargs -I 'url' curl -L -o gcm-public.asc 'url' +curl -Os https://packages.microsoft.com/keys/microsoft-2025.asc # Import the public key -gpg --import gcm-public.asc +gpg --import microsoft-2025.asc # Download the tarball and its signature file curl -s https://api.github.com/repos/ldennington/git-credential-manager/releases/latest \ @@ -78,7 +70,7 @@ curl -s https://api.github.com/repos/ldennington/git-credential-manager/releases | xargs -I 'url' curl -LO 'url' # Trust the public key -echo -e "5\ny\n" | gpg --command-fd 0 --expert --edit-key 3C853823978B07FA trust +echo -e "5\ny\n" | gpg --command-fd 0 --expert --edit-key EE4D7792F748182B trust # Verify the signature gpg --verify gcm-linux_amd64*.tar.gz.asc gcm-linux*.tar.gz From cd57ef859aedbbed9de1fb567fb47b6ce7c5b76f Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 7 Jul 2026 09:59:44 +0200 Subject: [PATCH 47/54] linux: fix instructions where to download the latest archive Most users will want to stick to Debian packages. Those who have to resort to the archive will want to download them from the correct location. Signed-off-by: Johannes Schindelin --- docs/linux-validate-gpg.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/linux-validate-gpg.md b/docs/linux-validate-gpg.md index d252f4cc97..d19caae96e 100644 --- a/docs/linux-validate-gpg.md +++ b/docs/linux-validate-gpg.md @@ -63,7 +63,7 @@ curl -Os https://packages.microsoft.com/keys/microsoft-2025.asc gpg --import microsoft-2025.asc # Download the tarball and its signature file -curl -s https://api.github.com/repos/ldennington/git-credential-manager/releases/latest \ +curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ | grep -E 'browser_download_url.*gcm-linux.*[0-9].[0-9].[0-9].tar.gz' \ | cut -d : -f 2,3 \ | tr -d \" \ From 4f4d57226a59b292f28c38f27f426901d0f7edcb Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 7 Jul 2026 09:16:01 +0100 Subject: [PATCH 48/54] VERSION: bump to 2.9.1 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 45a92322df..111f6e3a6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.9.0.0 +2.9.1.0 From 6760f0ef069c994aa2bb1d703fb374986ee82a3e Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 7 Jul 2026 09:54:25 +0100 Subject: [PATCH 49/54] release: manually force CFS on release builds Explicitly use Central Feed Services (CFS) feeds for NuGet packages by replacing the normal, nuget.org, config file in the repo root at the start of the build jobs. This is required for compliance, and the auto-injected task that is supposed to do this automatically is flakey (it doesn't run sometimes?!) so do this manually. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/nuget.config | 11 +++++++++++ .azure-pipelines/release.yml | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .azure-pipelines/nuget.config diff --git a/.azure-pipelines/nuget.config b/.azure-pipelines/nuget.config new file mode 100644 index 0000000000..0cdfa50d8e --- /dev/null +++ b/.azure-pipelines/nuget.config @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index a957609d89..1e1562dd74 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -134,6 +134,15 @@ extends: artifactName: '${{ dim.runtime }}' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)\.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: PowerShell@2 displayName: 'Read version file' inputs: @@ -295,6 +304,15 @@ extends: artifactName: '${{ dim.runtime }}' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: Bash@3 displayName: 'Read version file' inputs: @@ -570,6 +588,15 @@ extends: artifactName: '${{ dim.runtime }}' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: Bash@3 displayName: 'Read version file' inputs: @@ -673,6 +700,15 @@ extends: artifactName: 'dotnet-tool' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)\.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: PowerShell@2 displayName: 'Read version file' inputs: From e788575a1aab2bccc9c2d83ca8b2c943e5c4b0a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:33:41 +0000 Subject: [PATCH 50/54] build(deps): bump github/codeql-action from 4 to 4.37.4 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 72d5418879..439a8ace56 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -31,7 +31,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.4 with: languages: ${{ matrix.language }} @@ -39,4 +39,4 @@ jobs: dotnet build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.4 From fd1ba4c8df4c2345b34a40574fc3b088840271da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:32:40 +0000 Subject: [PATCH 51/54] build(deps): bump DavidAnson/markdownlint-cli2-action Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.0.0 to 24.2.0. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/ce4853d43830c74c1753b39f3cf40f71c2031eb9...21c1be1b93ad9ed58fa840aacc3f279cde2a72ff) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 24.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/lint-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index ff64b9bcd2..ad045c4abc 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 + - uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff with: globs: | "**/*.md" From 588716185d71f5219677a524c6a850ec342ea43b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 14 Aug 2026 15:05:14 +0200 Subject: [PATCH 52/54] lint-docs: explicitly limit permissions to read-only The pinned lychee-action downloads and executes a separate binary without verifying its digest (please find the relevant code here: https://github.com/lycheeverse/lychee-action/blob/e7477775783e/action.yml#L64-L117). But https://github.com/lycheeverse/lychee/releases/tag/lychee-v0.24.2, i.e. that binary's release, is mutable. This release-artifact gap is a relatively close analogue to the what https://www.cisa.gov/news-events/alerts/2024/03/29/reported-supply-chain-compromise-affecting-xz-utils-data-compression-library-cve-2024-3094 describes, and which has become known as "the XZ Utils backdoor". Let's close this gap at least as much as we can from our side, and hope that attacks like the now-finally-fixed Actions cache poisining (see https://github.com/AdnaneKhan/ActionsCacheBlasting/), i.e. attacks that work even in read-only mode as long as they are run on the repository's `main` branch, don't come back to bite us. Signed-off-by: Johannes Schindelin --- .github/workflows/lint-docs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index ff64b9bcd2..aaad11e454 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -13,6 +13,9 @@ on: - '**.md' - '.github/workflows/lint-docs.yml' +permissions: + contents: read + jobs: lint-markdown: name: Lint markdown files From b685a11eb457e78a0947cb2df453ba0da0f278bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:32:27 +0000 Subject: [PATCH 53/54] build(deps): bump lycheeverse/lychee-action from 2.8.0 to 2.9.0 Bumps [lycheeverse/lychee-action](https://github.com/lycheeverse/lychee-action) from 2.8.0 to 2.9.0. - [Release notes](https://github.com/lycheeverse/lychee-action/releases) - [Commits](https://github.com/lycheeverse/lychee-action/compare/8646ba30535128ac92d33dfc9133794bfdd9b411...e7477775783ea5526144ba13e8db5eec57747ce8) --- updated-dependencies: - dependency-name: lycheeverse/lychee-action dependency-version: 2.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/lint-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index aaad11e454..caa553521b 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -38,7 +38,7 @@ jobs: - name: Run link checker # For any troubleshooting, see: # https://github.com/lycheeverse/lychee/blob/master/docs/TROUBLESHOOTING.md - uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 with: # user-agent: if a user agent is not specified, some websites (e.g. # GitHub Docs) return HTTP errors which Lychee will interpret as From f9ae22b6f219bfb3fd12ced336d68d669c426c8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:33:47 +0000 Subject: [PATCH 54/54] build(deps): bump github/codeql-action from 4.37.4 to 4.37.6 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 439a8ace56..de584f174f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -31,7 +31,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.6 with: languages: ${{ matrix.language }} @@ -39,4 +39,4 @@ jobs: dotnet build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.6