diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index f020c676fe..a957609d89 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: @@ -862,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: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..39bbd2681d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": {} +} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d2b88817b4..72d5418879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,10 +22,10 @@ jobs: language: [ 'csharp' ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.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 a77a8d66d3..9997aa42ae 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -25,10 +25,10 @@ jobs: os: windows-11-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -82,10 +82,10 @@ 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.1.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -126,10 +126,10 @@ jobs: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x 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/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 }}'); diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index beecc9ae19..dca6f56b46 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: @@ -15,7 +18,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 @@ -42,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 diff --git a/.github/workflows/validate-install-homebrew.yml b/.github/workflows/validate-install-homebrew.yml new file mode 100644 index 0000000000..1c02e0c8c1 --- /dev/null +++ b/.github/workflows/validate-install-homebrew.yml @@ -0,0 +1,43 @@ +name: validate-install-homebrew + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + homebrew: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + - os: macos-14 + arch: arm64 + + steps: + - uses: actions/checkout@v7 + + - name: Install via Homebrew + run: brew install --cask git-credential-manager + + - name: Verify installation + run: git-credential-manager --version || exit 1 + + - name: Uninstall via Homebrew + run: brew uninstall --cask git-credential-manager + + - name: Verify uninstallation + run: | + if command -v git-credential-manager &>/dev/null; then + echo "ERROR: git-credential-manager still found after uninstall" + exit 1 + fi + echo "git-credential-manager successfully removed" 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/README.md b/README.md index b9dcff451c..f7ffa9df83 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,34 @@ -# Git Credential Manager +# pamela [![Build Status][build-status-badge]][workflow-status] --- -[Git Credential Manager][gcm] (GCM) is a secure +[pamela][gcm] is a secure [Git credential helper][git-credential-helper] built on [.NET][dotnet] that runs on Windows, macOS, and Linux. It aims to provide a consistent and secure authentication experience, including multi-factor auth, to every major source control hosting service and platform. -GCM supports (in alphabetical order) [Azure DevOps][azure-devops], Azure DevOps +pamela supports (in alphabetical order) [Azure DevOps][azure-devops], Azure DevOps Server (formerly Team Foundation Server), Bitbucket, GitHub, and GitLab. Compare to Git's [built-in credential helpers][git-tools-credential-storage] (Windows: wincred, macOS: osxkeychain, Linux: gnome-keyring/libsecret), which provide single-factor authentication support for username/password only. -GCM replaces both the .NET Framework-based +pamela replaces both the .NET Framework-based [Git Credential Manager for Windows][gcm-for-windows] and the Java-based [Git Credential Manager for Mac and Linux][gcm-for-mac-and-linux]. ## Install -See the [installation instructions][install] for the current version of GCM for +See the [installation instructions][install] for the current version of pamela for install options for your operating system. ## Current status -Git Credential Manager is currently available for Windows, macOS, and Linux\*. -GCM only works with HTTP(S) remotes; you can still use Git with SSH: +pamela is currently available for Windows, macOS, and Linux\*. +pamela only works with HTTP(S) remotes; you can still use Git with SSH: - [Azure DevOps SSH][azure-devops-ssh] - [GitHub SSH][github-ssh] @@ -50,37 +50,37 @@ Proxy support|✓|✓|✓ `arm64` support|best effort|✓|✓ `armhf` support|_N/A_|_N/A_|✓ -(\*) GCM guarantees support only for [the Linux distributions that are officially +(\*) pamela guarantees support only for [the Linux distributions that are officially supported by dotnet][dotnet-distributions]. ## Supported Git versions -Git Credential Manager tries to be compatible with the broadest set of Git +pamela tries to be compatible with the broadest set of Git versions (within reason). However there are some known problematic releases of Git that are not compatible. - Git 1.x - The initial major version of Git is not supported or tested with GCM. + The initial major version of Git is not supported or tested with pamela. - Git 2.26.2 This version of Git introduced a breaking change with parsing credential - configuration that GCM relies on. This issue was fixed in commit + configuration that pamela relies on. This issue was fixed in commit [`12294990`][gcm-commit-12294990] of the Git project, and released in Git 2.27.0. ## How to use -Once it's installed and configured, Git Credential Manager is called implicitly -by Git. You don't have to do anything special, and GCM isn't intended to be +Once it's installed and configured, pamela is called implicitly +by Git. You don't have to do anything special, and pamela isn't intended to be called directly by the user. For example, when pushing (`git push`) to [Azure DevOps][azure-devops], [Bitbucket][bitbucket], or [GitHub][github], a window will automatically open and walk you through the sign-in process. (This process will look slightly different for each Git host, and even in some cases, whether you've connected to an on-premises or cloud-hosted Git host.) Later Git commands in the same repository will re-use existing credentials or tokens that -GCM has stored for as long as they're valid. +pamela has stored for as long as they're valid. Read full command line usage [here][gcm-usage]. @@ -98,7 +98,7 @@ See the [documentation index][docs-index] for links to additional resources. ## Future features -Curious about what's coming next in the GCM project? Take a look at the [project +Curious about what's coming next in the pamela project? Take a look at the [project roadmap][roadmap]! You can find more details about the construction of the roadmap and how to interpret it [here][roadmap-announcement]. diff --git a/VERSION b/VERSION index 3646086447..0ab902011a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.3.0 +2.8.0.0 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 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/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" } } diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index 888a23597f..1337b75273 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 ;; @@ -220,7 +220,7 @@ case "$distribution" in ensure_dotnet_installed ;; - arch) + arch | cachyos) print_unsupported_distro "WARNING" "$distribution" # --noconfirm required when running from container 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 diff --git a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx index d7e6058e85..165edd0c8a 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx +++ b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx @@ -22,10 +22,20 @@ + Bitbucket Authentication +
@@ -53,11 +63,20 @@ + Bitbucket Authentication - +
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() { 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.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.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.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/Application.cs b/src/shared/Core/Application.cs index ab5266460f..acdb6e27bf 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) { @@ -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); } 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/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( 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 @@ + 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 }) { 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/Core/Git.cs b/src/shared/Core/Git.cs index 0c58e0159d..82588357cd 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(); @@ -167,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 @@ -267,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); 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 { 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; 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= 0; i--) { - using (var writer = _writers[i]) + using (_writers[i]) { - _writers.Remove(writer); + _writers.RemoveAt(i); } } } @@ -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"; } 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; 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); } } 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. 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
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) { 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. /// diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index ace93b63ed..b625be44df 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -43,7 +43,7 @@ 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"