diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 745027d8b..a01f294c5 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 27834c10e..14fe0aa07 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -54,12 +54,15 @@ jobs:
linux:
name: Linux
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ runtime: [ linux-x64, linux-arm64, linux-arm ]
steps:
- uses: actions/checkout@v4
- name: Setup .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -67,7 +70,10 @@ jobs:
run: dotnet restore
- name: Build
- run: dotnet build --configuration LinuxRelease
+ run: |
+ dotnet build src/linux/Packaging.Linux/*.csproj \
+ --configuration=Release --no-self-contained \
+ --runtime=${{ matrix.runtime }}
- name: Test
run: |
@@ -82,7 +88,7 @@ jobs:
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: linux-x64
+ name: ${{ matrix.runtime }}
path: |
artifacts
@@ -100,7 +106,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml
new file mode 100644
index 000000000..45e807719
--- /dev/null
+++ b/.github/workflows/dotnet-desktop.yml
@@ -0,0 +1,115 @@
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+# This workflow will build, test, sign and package a WPF or Windows Forms desktop application
+# built on .NET Core.
+# To learn how to migrate your existing application to .NET Core,
+# refer to https://docs.microsoft.com/en-us/dotnet/desktop-wpf/migration/convert-project-from-net-framework
+#
+# To configure this workflow:
+#
+# 1. Configure environment variables
+# GitHub sets default environment variables for every workflow run.
+# Replace the variables relative to your project in the "env" section below.
+#
+# 2. Signing
+# Generate a signing certificate in the Windows Application
+# Packaging Project or add an existing signing certificate to the project.
+# Next, use PowerShell to encode the .pfx file using Base64 encoding
+# by running the following Powershell script to generate the output string:
+#
+# $pfx_cert = Get-Content '.\SigningCertificate.pfx' -Encoding Byte
+# [System.Convert]::ToBase64String($pfx_cert) | Out-File 'SigningCertificate_Encoded.txt'
+#
+# Open the output file, SigningCertificate_Encoded.txt, and copy the
+# string inside. Then, add the string to the repo as a GitHub secret
+# and name it "Base64_Encoded_Pfx."
+# For more information on how to configure your signing certificate for
+# this workflow, refer to https://github.com/microsoft/github-actions-for-desktop-apps#signing
+#
+# Finally, add the signing certificate password to the repo as a secret and name it "Pfx_Key".
+# See "Build the Windows Application Packaging project" below to see how the secret is used.
+#
+# For more information on GitHub Actions, refer to https://github.com/features/actions
+# For a complete CI/CD sample to get started with GitHub Action workflows for Desktop Applications,
+# refer to https://github.com/microsoft/github-actions-for-desktop-apps
+
+name: .NET Core Desktop
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+
+ build:
+
+ strategy:
+ matrix:
+ configuration: [Debug, Release]
+
+ runs-on: windows-latest # For a list of available runner types, refer to
+ # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on
+
+ env:
+ Solution_Name: your-solution-name # Replace with your solution name, i.e. MyWpfApp.sln.
+ Test_Project_Path: your-test-project-path # Replace with the path to your test project, i.e. MyWpfApp.Tests\MyWpfApp.Tests.csproj.
+ Wap_Project_Directory: your-wap-project-directory-name # Replace with the Wap project directory relative to the solution, i.e. MyWpfApp.Package.
+ Wap_Project_Path: your-wap-project-path # Replace with the path to your Wap project, i.e. MyWpf.App.Package\MyWpfApp.Package.wapproj.
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # Install the .NET Core workload
+ - name: Install .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild
+ - name: Setup MSBuild.exe
+ uses: microsoft/setup-msbuild@v2
+
+ # Execute all unit tests in the solution
+ - name: Execute unit tests
+ run: dotnet test
+
+ # Restore the application to populate the obj folder with RuntimeIdentifiers
+ - name: Restore the application
+ run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
+ env:
+ Configuration: ${{ matrix.configuration }}
+
+ # Decode the base 64 encoded pfx and save the Signing_Certificate
+ - name: Decode the pfx
+ run: |
+ $pfx_cert_byte = [System.Convert]::FromBase64String("${{ secrets.Base64_Encoded_Pfx }}")
+ $certificatePath = Join-Path -Path $env:Wap_Project_Directory -ChildPath GitHubActionsWorkflow.pfx
+ [IO.File]::WriteAllBytes("$certificatePath", $pfx_cert_byte)
+
+ # Create the app package by building and packaging the Windows Application Packaging project
+ - name: Create the app package
+ run: msbuild $env:Wap_Project_Path /p:Configuration=$env:Configuration /p:UapAppxPackageBuildMode=$env:Appx_Package_Build_Mode /p:AppxBundle=$env:Appx_Bundle /p:PackageCertificateKeyFile=GitHubActionsWorkflow.pfx /p:PackageCertificatePassword=${{ secrets.Pfx_Key }}
+ env:
+ Appx_Bundle: Always
+ Appx_Bundle_Platforms: x86|x64
+ Appx_Package_Build_Mode: StoreUpload
+ Configuration: ${{ matrix.configuration }}
+
+ # Remove the pfx
+ - name: Remove the pfx
+ run: Remove-Item -path $env:Wap_Project_Directory\GitHubActionsWorkflow.pfx
+
+ # Upload the MSIX package: https://github.com/marketplace/actions/upload-a-build-artifact
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: MSIX Package
+ path: ${{ env.Wap_Project_Directory }}\AppPackages
diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml
index 4cf4c4b70..5f60867eb 100644
--- a/.github/workflows/lint-docs.yml
+++ b/.github/workflows/lint-docs.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: DavidAnson/markdownlint-cli2-action@b4c9feab76d8025d1e83c653fa3990936df0e6c8
+ - uses: DavidAnson/markdownlint-cli2-action@eb5ca3ab411449c66620fe7f1b3c9e10547144b0
with:
globs: |
"**/*.md"
@@ -35,7 +35,7 @@ jobs:
- name: Run link checker
# For any troubleshooting, see:
# https://github.com/lycheeverse/lychee/blob/master/docs/TROUBLESHOOTING.md
- uses: lycheeverse/lychee-action@c053181aa0c3d17606addfe97a9075a32723548a
+ uses: lycheeverse/lychee-action@f81112d0d2814ded911bd23e3beaa9dda9093915
with:
# user-agent: if a user agent is not specified, some websites (e.g.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9f578e109..4deee3502 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -57,8 +57,8 @@ jobs:
- name: Set up signing/notarization infrastructure
env:
- A1: ${{ secrets.APPLICATION_CERTIFICATE_BASE64 }}
- A2: ${{ secrets.APPLICATION_CERTIFICATE_PASSWORD }}
+ A1: ${{ secrets.GATEWATCHER_DEVELOPER_ID_CERT }}
+ A2: ${{ secrets.GATEWATCHER_DEVELOPER_ID_PASSWORD }}
I1: ${{ secrets.INSTALLER_CERTIFICATE_BASE64 }}
I2: ${{ secrets.INSTALLER_CERTIFICATE_PASSWORD }}
N1: ${{ secrets.APPLE_TEAM_ID }}
@@ -150,7 +150,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -177,7 +177,7 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign payload files with Azure Code Signing
- uses: azure/trusted-signing-action@v0.4.0
+ uses: azure/trusted-signing-action@v0.5.0
with:
endpoint: https://wus2.codesigning.azure.net/
trusted-signing-account-name: git-fundamentals-signing
@@ -190,7 +190,7 @@ jobs:
# The Azure Code Signing action overrides the .NET version, so we reset it.
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -204,7 +204,7 @@ jobs:
-Destination $env:GITHUB_WORKSPACE\installers
- name: Sign installers with Azure Code Signing
- uses: azure/trusted-signing-action@v0.4.0
+ uses: azure/trusted-signing-action@v0.5.0
with:
endpoint: https://wus2.codesigning.azure.net/
trusted-signing-account-name: git-fundamentals-signing
@@ -232,16 +232,22 @@ jobs:
runs-on: ubuntu-latest
environment: release
needs: prereqs
+ strategy:
+ matrix:
+ runtime: [ linux-x64, linux-arm64, linux-arm ]
steps:
- uses: actions/checkout@v4
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
- name: Build
- run: dotnet build --configuration=LinuxRelease
+ run: |
+ dotnet build src/linux/Packaging.Linux/*.csproj \
+ --configuration=LinuxRelease --no-self-contained \
+ --runtime=${{ matrix.runtime }}
- name: Run Linux unit tests
run: |
@@ -286,18 +292,18 @@ jobs:
run: |
# Sign Debian package
version=${{ needs.prereqs.outputs.version }}
- mv out/linux/Packaging.Linux/Release/deb/gcm-linux_amd64.$version.deb .
- debsigs --sign=origin --verify --check gcm-linux_amd64.$version.deb
+ mv out/linux/Packaging.Linux/Release/deb/gcm-${{ matrix.runtime }}.$version.deb .
+ debsigs --sign=origin --verify --check gcm-${{ matrix.runtime }}.$version.deb
# Generate tarball signature file
mv -v out/linux/Packaging.Linux/Release/tar/* .
- gpg --batch --yes --armor --output gcm-linux_amd64.$version.tar.gz.asc \
- --detach-sig gcm-linux_amd64.$version.tar.gz
+ gpg --batch --yes --armor --output gcm-${{ matrix.runtime }}.$version.tar.gz.asc \
+ --detach-sig gcm-${{ matrix.runtime }}.$version.tar.gz
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: linux-artifacts
+ name: ${{ matrix.runtime }}-artifacts
path: |
./*.deb
./*.asc
@@ -314,7 +320,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -387,7 +393,7 @@ jobs:
path: signed
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -466,9 +472,9 @@ jobs:
matrix:
component:
- os: ubuntu-latest
- artifact: linux-artifacts
+ artifact: linux-x64-artifacts
command: git-credential-manager
- description: linux
+ description: linux-x64
- os: macos-latest
artifact: macos-osx-x64-artifacts
command: git-credential-manager
@@ -491,7 +497,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -510,15 +516,15 @@ jobs:
Start-Process -Wait -FilePath "$exePath" -ArgumentList "/SILENT /VERYSILENT /NORESTART"
}
- - name: Install Linux (Debian package)
- if: contains(matrix.component.description, 'linux')
+ - name: Install Linux x64 (Debian package)
+ if: contains(matrix.component.description, 'linux-x64')
run: |
debpath=$(find ./*.deb)
sudo apt install $debpath
"${{ matrix.component.command }}" configure
- - name: Install Linux (tarball)
- if: contains(matrix.component.description, 'linux')
+ - name: Install Linux x64 (tarball)
+ if: contains(matrix.component.description, 'linux-x64')
run: |
# Ensure we find only the source tarball, not the symbols
tarpath=$(find . -name '*[[:digit:]].tar.gz')
@@ -561,7 +567,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up .NET
- uses: actions/setup-dotnet@v4.0.1
+ uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
@@ -598,7 +604,9 @@ jobs:
az keyvault secret show --name "$GPG_PUBLIC_KEY_SECRET_NAME" \
--vault-name "$AZURE_VAULT" --query "value" \
| sed -e 's/^"//' -e 's/"$//' | base64 -d >gcm-public.asc
- mv gcm-public.asc linux-artifacts
+ cp gcm-public.asc linux-x64-artifacts/
+ cp gcm-public.asc linux-arm64-artifacts/
+ mv gcm-public.asc linux-arm-artifacts
- uses: actions/github-script@v7
with:
@@ -655,7 +663,9 @@ jobs:
uploadDirectoryToRelease('osx-payload-and-symbols'),
// Upload Linux artifacts
- uploadDirectoryToRelease('linux-artifacts'),
+ uploadDirectoryToRelease('linux-x64-artifacts'),
+ uploadDirectoryToRelease('linux-arm64-artifacts'),
+ uploadDirectoryToRelease('linux-arm-artifacts'),
// Upload .NET tool package
uploadDirectoryToRelease('dotnet-tool-sign'),
diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml
index ca57d2daf..2b1fd7696 100644
--- a/.github/workflows/validate-install-from-source.yml
+++ b/.github/workflows/validate-install-from-source.yml
@@ -21,13 +21,14 @@ jobs:
# tgagor is a contributor who pushes updated images weekly, which should
# be sufficient for our validation needs.
- image: tgagor/centos
- - image: tgagor/centos-stream
- image: redhat/ubi8
- image: alpine
- image: alpine:3.14.10
- image: opensuse/leap
- image: opensuse/tumbleweed
- image: registry.suse.com/suse/sle15:15.4.27.11.31
+ - image: archlinux
+ - image: mcr.microsoft.com/cbl-mariner/base/core:2.0
container: ${{matrix.vector.image}}
steps:
- run: |
@@ -35,6 +36,9 @@ jobs:
zypper -n install tar gzip
elif [[ ${{matrix.vector.image}} == *"centos"* ]]; then
dnf install which -y
+ elif [[ ${{matrix.vector.image}} == *"mariner"* ]]; then
+ GNUPGHOME=/root/.gnupg tdnf update -y &&
+ GNUPGHOME=/root/.gnupg tdnf install tar -y # needed for `actions/checkout`
fi
- uses: actions/checkout@v4
diff --git a/README.md b/README.md
index 18c9b1309..6d1692f64 100644
--- a/README.md
+++ b/README.md
@@ -47,8 +47,8 @@ Basic HTTP authentication support|✓|✓|✓
Proxy support|✓|✓|✓
`amd64` support|✓|✓|✓
`x86` support|✓|_N/A_|✗
-`arm64` support|best effort|✓|best effort, no packages
-`armhf` support|_N/A_|_N/A_|best effort, no packages
+`arm64` support|best effort|✓|✓
+`armhf` support|_N/A_|_N/A_|✓
(\*) GCM guarantees support only for [the Linux distributions that are officially
supported by dotnet][dotnet-distributions].
@@ -144,3 +144,4 @@ When using GitHub logos, please be sure to follow the
[roadmap]: https://github.com/git-ecosystem/git-credential-manager/milestones?direction=desc&sort=due_date&state=open
[roadmap-announcement]: https://github.com/git-ecosystem/git-credential-manager/discussions/1203
[workflow-status]: https://github.com/git-ecosystem/git-credential-manager/actions/workflows/continuous-integration.yml
+c
\ No newline at end of file
diff --git a/VERSION b/VERSION
index 82f00d533..cfad4122e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.6.0.0
+2.6.1.0
diff --git a/build/GCM.MSBuild.csproj b/build/GCM.MSBuild.csproj
index 3df4909e6..b1980e3f6 100644
--- a/build/GCM.MSBuild.csproj
+++ b/build/GCM.MSBuild.csproj
@@ -10,4 +10,15 @@
-
+
+
+
+
+
+
+ cobertura,lcov
+
+
+
+
+
diff --git a/build/GenerateWindowsAppManifest.cs b/build/GenerateWindowsAppManifest.cs
index 58a94c5a1..a34353eb9 100644
--- a/build/GenerateWindowsAppManifest.cs
+++ b/build/GenerateWindowsAppManifest.cs
@@ -49,3 +49,4 @@ public override bool Execute()
}
}
}
+?!
\ No newline at end of file
diff --git a/docs/configuration.md b/docs/configuration.md
index a4fecf395..ba978ef30 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -255,6 +255,24 @@ Defaults to false (use hardware acceleration where available).
---
+### credential.allowUnsafeRemotes
+
+Allow transmitting credentials to unsafe remote URLs such as unencrypted HTTP
+URLs. This setting is not recommended for general use and should only be used
+when necessary.
+
+Defaults false (disallow unsafe remote URLs).
+
+#### Example
+
+```shell
+git config --global credential.allowUnsafeRemotes true
+```
+
+**Also see: [GCM_ALLOW_UNSAFE_REMOTES][gcm-allow-unsafe-remotes]**
+
+---
+
### credential.autoDetectTimeout
Set the maximum length of time, in milliseconds, that GCM should wait for a
@@ -567,6 +585,7 @@ _(unset)_|Windows: `wincredman`, macOS: `keychain`, Linux: _(none)_|-
`gpg`|Use GPG to store encrypted files that are compatible with the [pass][pass] (requires GPG and `pass` to initialize the store).|macOS, Linux
`cache`|Git's built-in [credential cache][credential-cache].|macOS, Linux
`plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`credential.plaintextStorePath`][credential-plaintextstorepath].|Windows, macOS, Linux
+`none`|Do not store credentials via GCM.|Windows, macOS, Linux
#### Example
@@ -1022,8 +1041,9 @@ Defaults to disabled.
[devbox]: https://azure.microsoft.com/en-us/products/dev-box
[enterprise-config]: enterprise-config.md
[envars]: environment.md
-[freedesktop-ss]: https://specifications.freedesktop.org/secret-service/
+[freedesktop-ss]: https://specifications.freedesktop.org/secret-service-spec/
[gcm-allow-windowsauth]: environment.md#GCM_ALLOW_WINDOWSAUTH
+[gcm-allow-unsafe-remotes]: environment.md#GCM_ALLOW_UNSAFE_REMOTES
[gcm-authority]: environment.md#GCM_AUTHORITY-deprecated
[gcm-autodetect-timeout]: environment.md#GCM_AUTODETECT_TIMEOUT
[gcm-azrepos-credentialtype]: environment.md#GCM_AZREPOS_CREDENTIALTYPE
diff --git a/docs/credstores.md b/docs/credstores.md
index 157eaf930..43811dc30 100644
--- a/docs/credstores.md
+++ b/docs/credstores.md
@@ -9,6 +9,7 @@ There are several options for storing credentials that GCM supports:
- GPG/[`pass`][passwordstore] compatible files
- Git's built-in [credential cache][credential-cache]
- Plaintext files
+- Passthrough/no-op (no credential store)
The default credential stores on macOS and Windows are the macOS Keychain and
the Windows Credential Manager, respectively.
@@ -251,13 +252,38 @@ permissions on this directory such that no other users or applications can
access files within. If possible, use a path that exists on an external volume
that you take with you and use full-disk encryption.
+## Passthrough/no-op (no credential store)
+
+**Available on:** _Windows, macOS, Linux_
+
+**:warning: .**
+
+```batch
+SET GCM_CREDENTIAL_STORE="none"
+```
+
+or
+
+```shell
+git config --global credential.credentialStore none
+```
+
+This option disables the internal credential store. All operations to store or
+retrieve credentials will do nothing, and will return success. This is useful if
+you want to use a different credential store, chained in sequence via Git
+configuration, and don't want GCM to store credentials.
+
+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
[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
[credential-store]: configuration.md#credentialcredentialstore
[credential-cache]: https://git-scm.com/docs/git-credential-cache
-[freedesktop-secret-service]: https://specifications.freedesktop.org/secret-service/
+[freedesktop-secret-service]: https://specifications.freedesktop.org/secret-service-spec/
[gcm-credential-store]: environment.md#GCM_CREDENTIAL_STORE
[git-credential-store]: https://git-scm.com/docs/git-credential-store
[mac-keychain-management]: https://support.apple.com/en-gb/guide/mac-help/mchlf375f392/mac
diff --git a/docs/development.md b/docs/development.md
index 7729556f9..0242d68b8 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -54,6 +54,12 @@ To build from the command line, run:
dotnet build -c LinuxDebug
```
+If you want to build for a specific architecture, you can provide `linux-x64` or `linux-arm64` or `linux-arm` as the runtime:
+
+```shell
+dotnet build -c LinuxDebug -r linux-arm64
+```
+
You can find a copy of the Debian package (.deb) file in `out/linux/Packaging.Linux/deb/Debug`.
The flat binaries can also be found in `out/linux/Packaging.Linux/payload/Debug`.
diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md
index bfdc7e302..97544a33f 100644
--- a/docs/enterprise-config.md
+++ b/docs/enterprise-config.md
@@ -55,7 +55,38 @@ those of the [Git configuration][config] settings.
The type of each registry key can be either `REG_SZ` (string) or `REG_DWORD`
(integer).
-## macOS/Linux
+## macOS
+
+Default settings values come from macOS's preferences system. Configuration
+profiles can be deployed to devices using a compatible Mobile Device Management
+(MDM) solution.
+
+Configuration for Git Credential Manager must take the form of a dictionary, set
+for the domain `git-credential-manager` under the key `configuration`. For
+example:
+
+```shell
+defaults write git-credential-manager configuration -dict-add
+```
+
+..where `` is the name of the settings from the [Git configuration][config]
+reference, and `` is the desired value.
+
+All values in the `configuration` dictionary must be strings. For boolean values
+use `true` or `false`, and for integer values use the number in string form.
+
+To read the current configuration:
+
+```console
+$ defaults read git-credential-manager configuration
+{
+ = ;
+ ...
+ = ;
+}
+```
+
+## Linux
Default configuration setting stores has not been implemented.
diff --git a/docs/environment.md b/docs/environment.md
index edda0d714..f321caa6c 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -302,6 +302,32 @@ Defaults to false (use hardware acceleration where available).
---
+### GCM_ALLOW_UNSAFE_REMOTES
+
+Allow transmitting credentials to unsafe remote URLs such as unencrypted HTTP
+URLs. This setting is not recommended for general use and should only be used
+when necessary.
+
+Defaults false (disallow unsafe remote URLs).
+
+#### Example
+
+##### Windows
+
+```batch
+SET GCM_ALLOW_UNSAFE_REMOTES=true
+```
+
+##### macOS/Linux
+
+```bash
+export GCM_ALLOW_UNSAFE_REMOTES=true
+```
+
+**Also see: [credential.allowUnsafeRemotes][credential-allowunsaferemotes]**
+
+---
+
### GCM_AUTODETECT_TIMEOUT
Set the maximum length of time, in milliseconds, that GCM should wait for a
@@ -690,6 +716,7 @@ _(unset)_|Windows: `wincredman`, macOS: `keychain`, Linux: _(none)_|-
`gpg`|Use GPG to store encrypted files that are compatible with the [`pass` utility][passwordstore] (requires GPG and `pass` to initialize the store).|macOS, Linux
`cache`|Git's built-in [credential cache][git-credential-cache].|Windows, macOS, Linux
`plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`GCM_PLAINTEXT_STORE_PATH`][gcm-plaintext-store-path].|Windows, macOS, Linux
+`none`|Do not store credentials via GCM.|Windows, macOS, Linux
#### Windows
@@ -1153,7 +1180,8 @@ Defaults to disabled.
[autodetect]: autodetect.md
[azure-access-tokens]: azrepos-users-and-tokens.md
[configuration]: configuration.md
-[credential-allowwindowsauth]: environment.md#credentialallowWindowsAuth
+[credential-allowwindowsauth]: configuration.md#credentialallowwindowsauth
+[credential-allowunsaferemotes]: configuration.md#credentialallowunsaferemotes
[credential-authority]: configuration.md#credentialauthority-deprecated
[credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout
[credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype
@@ -1182,7 +1210,7 @@ Defaults to disabled.
[credential-trace-msauth]: configuration.md#credentialtracemsauth
[default-values]: enterprise-config.md
[devbox]: https://azure.microsoft.com/en-us/products/dev-box
-[freedesktop-ss]: https://specifications.freedesktop.org/secret-service/
+[freedesktop-ss]: https://specifications.freedesktop.org/secret-service-spec/
[gcm]: usage.md
[gcm-interactive]: #gcm_interactive
[gcm-credential-store]: #gcm_credential_store
diff --git a/docs/install.md b/docs/install.md
index 4268858bb..5ae7b44d5 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 7.0 of the .NET
+**Note:** Make sure you have installed [version 8.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/docs/netconfig.md b/docs/netconfig.md
index cf312336f..920344f15 100644
--- a/docs/netconfig.md
+++ b/docs/netconfig.md
@@ -191,6 +191,22 @@ network traffic inspection tool such as [Telerik Fiddler][telerik-fiddler]. If
you are using such tools please consult their documentation for trusting the
proxy root certificates.
+---
+
+## Unsafe Remote URLs
+
+If you are using a remote URL that is not considered safe, such as unencrypted
+HTTP (remote URLs that start with `http://`), host providers may prevent you
+from authenticating with your credentials.
+
+In this case, you should consider using a HTTPS (starting with `https://`)
+remote URL to ensure your credentials are transmitted securely.
+
+If you accept the risks associated with using an unsafe remote URL, you can
+configure GCM to allow the use of unsafe remote URLS by setting the environment
+variable [`GCM_ALLOW_UNSAFE_REMOTES`][unsafe-envar], or by using the Git
+configuration option [`credential.allowUnsafeRemotes`][unsafe-config] to `true`.
+
[environment]: environment.md
[configuration]: configuration.md
[git-http-proxy]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy
@@ -212,3 +228,5 @@ proxy root certificates.
[git-ssl-no-verify]: https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_networking
[git-http-ssl-verify]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpsslVerify
[telerik-fiddler]: https://www.telerik.com/fiddler
+[unsafe-envar]: environment.md#gcm_allow_unsafe_remotes
+[unsafe-config]: configuration.md#credentialallowunsaferemotes
diff --git a/src/linux/Packaging.Linux/Packaging.Linux.csproj b/src/linux/Packaging.Linux/Packaging.Linux.csproj
index 8b9755c78..ddfb31500 100644
--- a/src/linux/Packaging.Linux/Packaging.Linux.csproj
+++ b/src/linux/Packaging.Linux/Packaging.Linux.csproj
@@ -24,8 +24,8 @@
-
-
+
+
diff --git a/src/linux/Packaging.Linux/build.sh b/src/linux/Packaging.Linux/build.sh
index 6672857d2..62352a7e8 100755
--- a/src/linux/Packaging.Linux/build.sh
+++ b/src/linux/Packaging.Linux/build.sh
@@ -30,6 +30,10 @@ case "$i" in
INSTALL_FROM_SOURCE="${i#*=}"
shift # past argument=value
;;
+ --runtime=*)
+ RUNTIME="${i#*=}"
+ shift # past argument=value
+ ;;
--install-prefix=*)
INSTALL_PREFIX="${i#*=}"
shift # past argument=value
@@ -41,10 +45,14 @@ esac
done
# Ensure install prefix exists
-if [! -d "$INSTALL_PREFIX" ]; then
+if [ ! -d "$INSTALL_PREFIX" ]; then
mkdir -p "$INSTALL_PREFIX"
fi
+if [ ! -z "$RUNTIME" ]; then
+ echo "Building for runtime ${RUNTIME}"
+fi
+
# Perform pre-execution checks
CONFIGURATION="${CONFIGURATION:=Debug}"
if [ -z "$VERSION" ]; then
@@ -56,7 +64,7 @@ PAYLOAD="$OUTDIR/payload"
SYMBOLS="$OUTDIR/payload.sym"
# Lay out payload
-"$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" || exit 1
+"$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" --runtime="$RUNTIME" || exit 1
if [ $INSTALL_FROM_SOURCE = true ]; then
echo "Installing to $INSTALL_PREFIX"
@@ -79,7 +87,7 @@ if [ $INSTALL_FROM_SOURCE = true ]; then
echo "Install complete."
else
# Pack
- "$INSTALLER_SRC/pack.sh" --configuration="$CONFIGURATION" --payload="$PAYLOAD" --symbols="$SYMBOLS" --version="$VERSION" || exit 1
+ "$INSTALLER_SRC/pack.sh" --configuration="$CONFIGURATION" --runtime="$RUNTIME" --payload="$PAYLOAD" --symbols="$SYMBOLS" --version="$VERSION" || exit 1
fi
echo "Build of Packaging.Linux complete."
diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh
index be6ea1579..8cf60251c 100755
--- a/src/linux/Packaging.Linux/install-from-source.sh
+++ b/src/linux/Packaging.Linux/install-from-source.sh
@@ -63,7 +63,7 @@ install_packages() {
for package in $packages; do
# Ensure we don't stomp on existing installations.
- if [ ! -z $(which $package) ]; then
+ if type $package >/dev/null 2>&1; then
continue
fi
@@ -228,7 +228,7 @@ case "$distribution" in
$sudo_cmd tdnf update -y
# Install dotnet/GCM dependencies.
- install_packages tdnf install "curl git krb5-libs libicu openssl-libs zlib findutils which bash"
+ install_packages tdnf install "curl ca-certificates git krb5-libs libicu openssl-libs zlib findutils which bash awk"
ensure_dotnet_installed
;;
diff --git a/src/linux/Packaging.Linux/layout.sh b/src/linux/Packaging.Linux/layout.sh
index 6679c39ca..ccf031156 100755
--- a/src/linux/Packaging.Linux/layout.sh
+++ b/src/linux/Packaging.Linux/layout.sh
@@ -23,6 +23,10 @@ case "$i" in
CONFIGURATION="${i#*=}"
shift # past argument=value
;;
+ --runtime=*)
+ RUNTIME="${i#*=}"
+ shift # past argument=value
+ ;;
*)
# unknown option
;;
@@ -39,7 +43,6 @@ PROJ_OUT="$OUT/linux/Packaging.Linux"
# Build parameters
FRAMEWORK=net8.0
-RUNTIME=linux-x64
# Perform pre-execution checks
CONFIGURATION="${CONFIGURATION:=Debug}"
@@ -69,13 +72,22 @@ fi
# Publish core application executables
echo "Publishing core application..."
-$DOTNET_ROOT/dotnet publish "$GCM_SRC" \
- --configuration="$CONFIGURATION" \
- --framework="$FRAMEWORK" \
- --runtime="$RUNTIME" \
- --self-contained \
- -p:PublishSingleFile=true \
- --output="$(make_absolute "$PAYLOAD")" || exit 1
+if [ -z "$RUNTIME" ]; then
+ $DOTNET_ROOT/dotnet publish "$GCM_SRC" \
+ --configuration="$CONFIGURATION" \
+ --framework="$FRAMEWORK" \
+ --self-contained \
+ -p:PublishSingleFile=true \
+ --output="$(make_absolute "$PAYLOAD")" || exit 1
+else
+ $DOTNET_ROOT/dotnet publish "$GCM_SRC" \
+ --configuration="$CONFIGURATION" \
+ --framework="$FRAMEWORK" \
+ --runtime="$RUNTIME" \
+ --self-contained \
+ -p:PublishSingleFile=true \
+ --output="$(make_absolute "$PAYLOAD")" || exit 1
+fi
# Collect symbols
echo "Collecting managed symbols..."
diff --git a/src/linux/Packaging.Linux/pack.sh b/src/linux/Packaging.Linux/pack.sh
index 14d26aee5..4cf5aaea7 100755
--- a/src/linux/Packaging.Linux/pack.sh
+++ b/src/linux/Packaging.Linux/pack.sh
@@ -28,6 +28,10 @@ case "$i" in
SYMBOLS="${i#*=}"
shift # past argument=value
;;
+ --runtime=*)
+ RUNTIME="${i#*=}"
+ shift # past argument=value
+ ;;
--configuration=*)
CONFIGURATION="${i#*=}"
shift # past argument=value
@@ -51,20 +55,17 @@ fi
if [ -z "$SYMBOLS" ]; then
die "--symbols was not set"
fi
-
-ARCH="`dpkg-architecture -q DEB_HOST_ARCH`"
-
-if test -z "$ARCH"; then
- die "Could not determine host architecture!"
+if [ -z "$RUNTIME" ]; then
+ die "--runtime was not set"
fi
TAROUT="$PROJ_OUT/$CONFIGURATION/tar/"
-TARBALL="$TAROUT/gcm-linux_$ARCH.$VERSION.tar.gz"
-SYMTARBALL="$TAROUT/gcm-linux_$ARCH.$VERSION-symbols.tar.gz"
+TARBALL="$TAROUT/gcm-$RUNTIME.$VERSION.tar.gz"
+SYMTARBALL="$TAROUT/gcm-$RUNTIME.$VERSION-symbols.tar.gz"
DEBOUT="$PROJ_OUT/$CONFIGURATION/deb"
DEBROOT="$DEBOUT/root"
-DEBPKG="$DEBOUT/gcm-linux_$ARCH.$VERSION.deb"
+DEBPKG="$DEBOUT/gcm-$RUNTIME.$VERSION.deb"
mkdir -p "$DEBROOT"
# Set full read, write, execute permissions for owner and just read and execute permissions for group and other
@@ -99,6 +100,42 @@ INSTALL_TO="$DEBROOT/usr/local/share/gcm-core/"
LINK_TO="$DEBROOT/usr/local/bin/"
mkdir -p "$DEBROOT/DEBIAN" "$INSTALL_TO" "$LINK_TO" || exit 1
+# Fall back to host architecture if no explicit runtime is given.
+if test -z "$RUNTIME"; then
+ HOST_ARCH="`dpkg-architecture -q DEB_HOST_ARCH`"
+
+ case $HOST_ARCH in
+ amd64)
+ RUNTIME="linux-x64"
+ ;;
+ arm64)
+ RUNTIME="linux-arm64"
+ ;;
+ armhf)
+ RUNTIME="linux-arm"
+ ;;
+ *)
+ die "Could not determine host architecture!"
+ ;;
+ esac
+fi
+
+# Determine architecture for debian control file from the runtime architecture
+case $RUNTIME in
+ linux-x64)
+ ARCH="amd64"
+ ;;
+ linux-arm64)
+ ARCH="arm64"
+ ;;
+ linux-arm)
+ ARCH="armhf"
+ ;;
+ *)
+ die "Incompatible runtime architecture given for pack.sh"
+ ;;
+esac
+
# make the debian control file
# this is purposefully not indented, see
# https://stackoverflow.com/questions/9349616/bash-eof-in-if-statement
diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs
index 35472682c..286398de9 100644
--- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs
+++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs
@@ -55,8 +55,8 @@ public bool IsSupported(InputArguments input)
return false;
}
- // We do not support unencrypted HTTP communications to Bitbucket,
- // but we report `true` here for HTTP so that we can show a helpful
+ // We do not recommend unencrypted HTTP communications to Bitbucket, but it is possible.
+ // Therefore, we report `true` here for HTTP so that we can show a helpful
// error message for the user in `GetCredentialAsync`.
return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") ||
StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) &&
@@ -81,11 +81,14 @@ public bool IsSupported(HttpResponseMessage response)
public async Task GetCredentialAsync(InputArguments input)
{
// We should not allow unencrypted communication and should inform the user
- if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")
- && BitbucketHelper.IsBitbucketOrg(input))
+ if (!_context.Settings.AllowUnsafeRemotes &&
+ StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") &&
+ BitbucketHelper.IsBitbucketOrg(input))
{
throw new Trace2Exception(_context.Trace2,
- "Unencrypted HTTP is not supported for Bitbucket.org. Ensure the repository remote URL is using HTTPS.");
+ "Unencrypted HTTP is not recommended for Bitbucket.org. " +
+ "Ensure the repository remote URL is using HTTPS " +
+ $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes.");
}
var authModes = await GetSupportedAuthenticationModesAsync(input);
diff --git a/src/shared/Core.Tests/GitStreamReaderTests.cs b/src/shared/Core.Tests/GitStreamReaderTests.cs
new file mode 100644
index 000000000..bf656d102
--- /dev/null
+++ b/src/shared/Core.Tests/GitStreamReaderTests.cs
@@ -0,0 +1,193 @@
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace GitCredentialManager.Tests;
+
+public class GitStreamReaderTests
+{
+ #region ReadLineAsync
+
+ [Fact]
+ public async Task GitStreamReader_ReadLineAsync_LF()
+ {
+ // hello\n
+ // world\n
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\nworld\n");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = await reader.ReadLineAsync();
+ string actual2 = await reader.ReadLineAsync();
+ string actual3 = await reader.ReadLineAsync();
+
+ Assert.Equal("hello", actual1);
+ Assert.Equal("world", actual2);
+ Assert.Null(actual3);
+ }
+
+ [Fact]
+ public async Task GitStreamReader_ReadLineAsync_CR()
+ {
+ // hello\rworld\r
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\rworld\r");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = await reader.ReadLineAsync();
+ string actual2 = await reader.ReadLineAsync();
+
+ Assert.Equal("hello\rworld\r", actual1);
+ Assert.Null(actual2);
+ }
+
+ [Fact]
+ public async Task GitStreamReader_ReadLineAsync_CRLF()
+ {
+ // hello\r\n
+ // world\r\n
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\r\n");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = await reader.ReadLineAsync();
+ string actual2 = await reader.ReadLineAsync();
+ string actual3 = await reader.ReadLineAsync();
+
+ Assert.Equal("hello", actual1);
+ Assert.Equal("world", actual2);
+ Assert.Null(actual3);
+ }
+
+ [Fact]
+ public async Task GitStreamReader_ReadLineAsync_Mixed()
+ {
+ // hello\r\n
+ // world\rthis\n
+ // is\n
+ // a\n
+ // \rmixed\rnewline\r\n
+ // \n
+ // string\n
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\rthis\nis\na\n\rmixed\rnewline\r\n\nstring\n");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = await reader.ReadLineAsync();
+ string actual2 = await reader.ReadLineAsync();
+ string actual3 = await reader.ReadLineAsync();
+ string actual4 = await reader.ReadLineAsync();
+ string actual5 = await reader.ReadLineAsync();
+ string actual6 = await reader.ReadLineAsync();
+ string actual7 = await reader.ReadLineAsync();
+ string actual8 = await reader.ReadLineAsync();
+
+ Assert.Equal("hello", actual1);
+ Assert.Equal("world\rthis", actual2);
+ Assert.Equal("is", actual3);
+ Assert.Equal("a", actual4);
+ Assert.Equal("\rmixed\rnewline", actual5);
+ Assert.Equal("", actual6);
+ Assert.Equal("string", actual7);
+ Assert.Null(actual8);
+ }
+
+ #endregion
+
+ #region ReadLine
+
+ [Fact]
+ public void GitStreamReader_ReadLine_LF()
+ {
+ // hello\n
+ // world\n
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\nworld\n");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = reader.ReadLine();
+ string actual2 = reader.ReadLine();
+ string actual3 = reader.ReadLine();
+
+ Assert.Equal("hello", actual1);
+ Assert.Equal("world", actual2);
+ Assert.Null(actual3);
+ }
+
+ [Fact]
+ public void GitStreamReader_ReadLine_CR()
+ {
+ // hello\rworld\r
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\rworld\r");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = reader.ReadLine();
+ string actual2 = reader.ReadLine();
+
+ Assert.Equal("hello\rworld\r", actual1);
+ Assert.Null(actual2);
+ }
+
+ [Fact]
+ public void GitStreamReader_ReadLine_CRLF()
+ {
+ // hello\r\n
+ // world\r\n
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\r\n");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = reader.ReadLine();
+ string actual2 = reader.ReadLine();
+ string actual3 = reader.ReadLine();
+
+ Assert.Equal("hello", actual1);
+ Assert.Equal("world", actual2);
+ Assert.Null(actual3);
+ }
+
+ [Fact]
+ public void GitStreamReader_ReadLine_Mixed()
+ {
+ // hello\r\n
+ // world\rthis\n
+ // is\n
+ // a\n
+ // \rmixed\rnewline\r\n
+ // \n
+ // string\n
+
+ byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\rthis\nis\na\n\rmixed\rnewline\r\n\nstring\n");
+ using var stream = new MemoryStream(buffer);
+ var reader = new GitStreamReader(stream, Encoding.UTF8);
+
+ string actual1 = reader.ReadLine();
+ string actual2 = reader.ReadLine();
+ string actual3 = reader.ReadLine();
+ string actual4 = reader.ReadLine();
+ string actual5 = reader.ReadLine();
+ string actual6 = reader.ReadLine();
+ string actual7 = reader.ReadLine();
+ string actual8 = reader.ReadLine();
+
+ Assert.Equal("hello", actual1);
+ Assert.Equal("world\rthis", actual2);
+ Assert.Equal("is", actual3);
+ Assert.Equal("a", actual4);
+ Assert.Equal("\rmixed\rnewline", actual5);
+ Assert.Equal("", actual6);
+ Assert.Equal("string", actual7);
+ Assert.Null(actual8);
+ }
+
+ #endregion
+}
diff --git a/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs b/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs
new file mode 100644
index 000000000..0efb14471
--- /dev/null
+++ b/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs
@@ -0,0 +1,66 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+using GitCredentialManager.Interop.MacOS;
+using static GitCredentialManager.Tests.TestUtils;
+
+namespace GitCredentialManager.Tests.Interop.MacOS;
+
+public class MacOSPreferencesTests
+{
+ private const string TestAppId = "com.example.gcm-test";
+ private const string DefaultsPath = "/usr/bin/defaults";
+
+ [MacOSFact]
+ public async Task MacOSPreferences_ReadPreferences()
+ {
+ try
+ {
+ await SetupTestPreferencesAsync();
+
+ var pref = new MacOSPreferences(TestAppId);
+
+ // Exists
+ string stringValue = pref.GetString("myString");
+ int? intValue = pref.GetInteger("myInt");
+ IDictionary dictValue = pref.GetDictionary("myDict");
+
+ Assert.NotNull(stringValue);
+ Assert.Equal("this is a string", stringValue);
+ Assert.NotNull(intValue);
+ Assert.Equal(42, intValue);
+ Assert.NotNull(dictValue);
+ Assert.Equal(2, dictValue.Count);
+ Assert.Equal("value1", dictValue["dict-k1"]);
+ Assert.Equal("value2", dictValue["dict-k2"]);
+
+ // Does not exist
+ string missingString = pref.GetString("missingString");
+ int? missingInt = pref.GetInteger("missingInt");
+ IDictionary missingDict = pref.GetDictionary("missingDict");
+
+ Assert.Null(missingString);
+ Assert.Null(missingInt);
+ Assert.Null(missingDict);
+ }
+ finally
+ {
+ await CleanupTestPreferencesAsync();
+ }
+ }
+
+ private static async Task SetupTestPreferencesAsync()
+ {
+ // Using the defaults command set up preferences for the test app
+ await RunCommandAsync(DefaultsPath, $"write {TestAppId} myString \"this is a string\"");
+ await RunCommandAsync(DefaultsPath, $"write {TestAppId} myInt -int 42");
+ await RunCommandAsync(DefaultsPath, $"write {TestAppId} myDict -dict dict-k1 value1 dict-k2 value2");
+ }
+
+ private static async Task CleanupTestPreferencesAsync()
+ {
+ // Delete the test app preferences
+ // defaults delete com.example.gcm-test
+ await RunCommandAsync(DefaultsPath, $"delete {TestAppId}");
+ }
+}
diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs
index 712db32e1..d3ef1dbf6 100644
--- a/src/shared/Core/CommandContext.cs
+++ b/src/shared/Core/CommandContext.cs
@@ -131,7 +131,7 @@ public CommandContext()
gitPath,
FileSystem.GetCurrentDirectory()
);
- Settings = new Settings(Environment, Git);
+ Settings = new MacOSSettings(Environment, Git, Trace);
}
else if (PlatformUtils.IsLinux())
{
diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs
index 210c991bc..4777b0cf8 100644
--- a/src/shared/Core/Constants.cs
+++ b/src/shared/Core/Constants.cs
@@ -16,6 +16,7 @@ public static class Constants
public const string GcmDataDirectoryName = ".gcm";
+ public const string MacOSBundleId = "git-credential-manager";
public static readonly Guid DevBoxPartnerId = new("e3171dd9-9a5f-e5be-b36c-cc7c4f3f3bcf");
///
@@ -38,6 +39,7 @@ public static class CredentialStoreNames
public const string SecretService = "secretservice";
public const string Plaintext = "plaintext";
public const string Cache = "cache";
+ public const string None = "none";
}
public static class RegexPatterns
@@ -119,6 +121,7 @@ public static class EnvironmentVariables
public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME";
public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS";
public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING";
+ public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES";
}
public static class Http
@@ -163,6 +166,7 @@ public static class Credential
public const string MsAuthUseDefaultAccount = "msauthUseDefaultAccount";
public const string GuiSoftwareRendering = "guiSoftwareRendering";
public const string GpgPassStorePath = "gpgPassStorePath";
+ public const string AllowUnsafeRemotes = "allowUnsafeRemotes";
public const string OAuthAuthenticationModes = "oauthAuthModes";
public const string OAuthClientId = "oauthClientId";
@@ -226,6 +230,7 @@ public static class HelpUrls
public const string GcmAutoDetect = "https://aka.ms/gcm/autodetect";
public const string GcmDefaultAccount = "https://aka.ms/gcm/defaultaccount";
public const string GcmMultipleUsers = "https://aka.ms/gcm/multipleusers";
+ public const string GcmUnsafeRemotes = "https://aka.ms/gcm/unsaferemotes";
}
private static Version _gcmVersion;
diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs
index 83f915d1e..11dc83818 100644
--- a/src/shared/Core/CredentialStore.cs
+++ b/src/shared/Core/CredentialStore.cs
@@ -100,6 +100,10 @@ private void EnsureBackingStore()
_backingStore = new PlaintextCredentialStore(_context.FileSystem, plainStoreRoot, ns);
break;
+ case StoreNames.None:
+ _backingStore = new NullCredentialStore();
+ break;
+
default:
var sb = new StringBuilder();
sb.AppendLine(string.IsNullOrWhiteSpace(credStoreName)
@@ -168,6 +172,9 @@ private static void AppendAvailableStoreList(StringBuilder sb)
sb.AppendFormat(" {1,-13} : store credentials in plain-text files (UNSECURE){0}",
Environment.NewLine, StoreNames.Plaintext);
+
+ sb.AppendFormat(" {1, -13} : disable internal credential storage{0}",
+ Environment.NewLine, StoreNames.None);
}
private void ValidateWindowsCredentialManager()
diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs
index 447e465d5..9f087ca5b 100644
--- a/src/shared/Core/GenericHostProvider.cs
+++ b/src/shared/Core/GenericHostProvider.cs
@@ -54,6 +54,17 @@ public override async Task GenerateCredentialAsync(InputArguments i
{
ThrowIfDisposed();
+ // We only want to *warn* about HTTP remotes for the generic provider because it supports all protocols
+ // and, historically, we never blocked HTTP remotes in this provider.
+ // The user can always set the 'GCM_ALLOW_UNSAFE' setting to silence the warning.
+ if (!Context.Settings.AllowUnsafeRemotes &&
+ StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"))
+ {
+ Context.Streams.Error.WriteLine(
+ "warning: use of unencrypted HTTP remote URLs is not recommended; " +
+ $"see {Constants.HelpUrls.GcmUnsafeRemotes} for more information.");
+ }
+
Uri uri = input.GetRemoteUri();
// Determine the if the host supports Windows Integration Authentication (WIA) or OAuth
diff --git a/src/shared/Core/GitStreamReader.cs b/src/shared/Core/GitStreamReader.cs
new file mode 100644
index 000000000..6512b2efc
--- /dev/null
+++ b/src/shared/Core/GitStreamReader.cs
@@ -0,0 +1,70 @@
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GitCredentialManager;
+
+///
+/// StreamReader that does NOT consider a lone carriage-return as a new-line character,
+/// only a line-feed or carriage-return immediately followed by a line-feed.
+///
+/// The only major operating system that uses a lone carriage-return as a new-line character
+/// is the classic Macintosh OS (before OS X), which is not supported by Git.
+///
+public class GitStreamReader : StreamReader
+{
+ public GitStreamReader(Stream stream, Encoding encoding) : base(stream, encoding) { }
+
+ public override string ReadLine()
+ {
+#if NETFRAMEWORK
+ return ReadLineAsync().ConfigureAwait(false).GetAwaiter().GetResult();
+#else
+ return ReadLineAsync(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
+#endif
+ }
+
+#if NETFRAMEWORK
+ public override async Task ReadLineAsync()
+#else
+ public override async ValueTask ReadLineAsync(CancellationToken cancellationToken)
+#endif
+ {
+ int nr;
+ var sb = new StringBuilder();
+ var buffer = new char[1];
+ bool lastWasCR = false;
+
+ while ((nr = await base.ReadAsync(buffer, 0, 1).ConfigureAwait(false)) > 0)
+ {
+ char c = buffer[0];
+
+ // Only treat a line-feed as a new-line character.
+ // Carriage-returns alone are NOT considered new-line characters.
+ if (c == '\n')
+ {
+ if (lastWasCR)
+ {
+ // If the last character was a carriage-return we should remove it from the string builder
+ // since together with this line-feed it is considered a new-line character.
+ sb.Length--;
+ }
+
+ // We have a new-line character, so we should stop reading.
+ break;
+ }
+
+ lastWasCR = c == '\r';
+
+ sb.Append(c);
+ }
+
+ if (sb.Length == 0 && nr == 0)
+ {
+ return null;
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs
index b024be129..9335e136d 100644
--- a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs
+++ b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs
@@ -302,35 +302,18 @@ private static string GetStringAttribute(IntPtr dict, IntPtr key)
return null;
}
- IntPtr buffer = IntPtr.Zero;
- try
+ if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero)
{
- if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero)
+ if (CFGetTypeID(value) == CFStringGetTypeID())
{
- if (CFGetTypeID(value) == CFStringGetTypeID())
- {
- int stringLength = (int)CFStringGetLength(value);
- int bufferSize = stringLength + 1;
- buffer = Marshal.AllocHGlobal(bufferSize);
- if (CFStringGetCString(value, buffer, bufferSize, CFStringEncoding.kCFStringEncodingUTF8))
- {
- return Marshal.PtrToStringAuto(buffer, stringLength);
- }
- }
-
- if (CFGetTypeID(value) == CFDataGetTypeID())
- {
- int length = CFDataGetLength(value);
- IntPtr ptr = CFDataGetBytePtr(value);
- return Marshal.PtrToStringAuto(ptr, length);
- }
+ return CFStringToString(value);
}
- }
- finally
- {
- if (buffer != IntPtr.Zero)
+
+ if (CFGetTypeID(value) == CFDataGetTypeID())
{
- Marshal.FreeHGlobal(buffer);
+ int length = CFDataGetLength(value);
+ IntPtr ptr = CFDataGetBytePtr(value);
+ return Marshal.PtrToStringAuto(ptr, length);
}
}
diff --git a/src/shared/Core/Interop/MacOS/MacOSPreferences.cs b/src/shared/Core/Interop/MacOS/MacOSPreferences.cs
new file mode 100644
index 000000000..f866b30a8
--- /dev/null
+++ b/src/shared/Core/Interop/MacOS/MacOSPreferences.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using GitCredentialManager.Interop.MacOS.Native;
+using static GitCredentialManager.Interop.MacOS.Native.CoreFoundation;
+
+namespace GitCredentialManager.Interop.MacOS;
+
+public class MacOSPreferences
+{
+ private readonly string _appId;
+
+ public MacOSPreferences(string appId)
+ {
+ EnsureArgument.NotNull(appId, nameof(appId));
+
+ _appId = appId;
+ }
+
+ ///
+ /// Return a typed value from the app preferences.
+ ///
+ /// Preference name.
+ /// Thrown if the preference is not a string.
+ ///
+ /// or null if the preference with the given key does not exist.
+ ///
+ public string GetString(string key)
+ {
+ return TryGet(key, CFStringToString, out string value)
+ ? value
+ : null;
+ }
+
+ ///
+ /// Return a typed value from the app preferences.
+ ///
+ /// Preference name.
+ /// Thrown if the preference is not an integer.
+ ///
+ /// or null if the preference with the given key does not exist.
+ ///
+ public int? GetInteger(string key)
+ {
+ return TryGet(key, CFNumberToInt32, out int value)
+ ? value
+ : null;
+ }
+
+ ///
+ /// Return a typed value from the app preferences.
+ ///
+ /// Preference name.
+ /// Thrown if the preference is not a dictionary.
+ ///
+ /// or null if the preference with the given key does not exist.
+ ///
+ public IDictionary GetDictionary(string key)
+ {
+ return TryGet(key, CFDictionaryToDictionary, out IDictionary value)
+ ? value
+ : null;
+ }
+
+ private bool TryGet(string key, Func converter, out T value)
+ {
+ IntPtr cfValue = IntPtr.Zero;
+ IntPtr keyPtr = IntPtr.Zero;
+ IntPtr appIdPtr = CreateAppIdPtr();
+
+ try
+ {
+ keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, CFStringEncoding.kCFStringEncodingUTF8);
+ cfValue = CFPreferencesCopyAppValue(keyPtr, appIdPtr);
+
+ if (cfValue == IntPtr.Zero)
+ {
+ value = default;
+ return false;
+ }
+
+ value = converter(cfValue);
+ return true;
+ }
+ finally
+ {
+ if (cfValue != IntPtr.Zero) CFRelease(cfValue);
+ if (keyPtr != IntPtr.Zero) CFRelease(keyPtr);
+ if (appIdPtr != IntPtr.Zero) CFRelease(appIdPtr);
+ }
+ }
+
+ private IntPtr CreateAppIdPtr()
+ {
+ return CFStringCreateWithCString(IntPtr.Zero, _appId, CFStringEncoding.kCFStringEncodingUTF8);
+ }
+}
diff --git a/src/shared/Core/Interop/MacOS/MacOSSettings.cs b/src/shared/Core/Interop/MacOS/MacOSSettings.cs
new file mode 100644
index 000000000..3ef2c8247
--- /dev/null
+++ b/src/shared/Core/Interop/MacOS/MacOSSettings.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+
+namespace GitCredentialManager.Interop.MacOS
+{
+ ///
+ /// Reads settings from Git configuration, environment variables, and defaults from the system.
+ ///
+ public class MacOSSettings : Settings
+ {
+ private readonly ITrace _trace;
+
+ public MacOSSettings(IEnvironment environment, IGit git, ITrace trace)
+ : base(environment, git)
+ {
+ EnsureArgument.NotNull(trace, nameof(trace));
+ _trace = trace;
+
+ PlatformUtils.EnsureMacOS();
+ }
+
+ protected override bool TryGetExternalDefault(string section, string scope, string property, out string value)
+ {
+ value = null;
+
+ try
+ {
+ // Check for app default preferences for our bundle ID.
+ // Defaults can be deployed system administrators via device management profiles.
+ var prefs = new MacOSPreferences(Constants.MacOSBundleId);
+ IDictionary dict = prefs.GetDictionary("configuration");
+
+ if (dict is null)
+ {
+ // No configuration key exists
+ return false;
+ }
+
+ // Wrap the raw dictionary in one configured with the Git configuration key comparer.
+ // This means we can use the same key comparison rules as Git in our configuration plist dict,
+ // That is, sections and names are insensitive to case, but the scope is case-sensitive.
+ var config = new Dictionary(dict, GitConfigurationKeyComparer.Instance);
+
+ string name = string.IsNullOrWhiteSpace(scope)
+ ? $"{section}.{property}"
+ : $"{section}.{scope}.{property}";
+
+ if (!config.TryGetValue(name, out value))
+ {
+ // No property exists
+ return false;
+ }
+
+ _trace.WriteLine($"Default setting found in app preferences: {name}={value}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Reading defaults is not critical to the operation of the application
+ // so we can ignore any errors and just log the failure.
+ _trace.WriteLine("Failed to read default setting from app preferences.");
+ _trace.WriteException(ex);
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs b/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs
index 0f32a383b..9cab2ca8f 100644
--- a/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs
+++ b/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Runtime.InteropServices;
using static GitCredentialManager.Interop.MacOS.Native.LibSystem;
@@ -55,6 +56,9 @@ public static extern void CFDictionaryAddValue(
public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, long numBytes,
CFStringEncoding encoding, bool isExternalRepresentation);
+ [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
+ public static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, CFStringEncoding encoding);
+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern long CFStringGetLength(IntPtr theString);
@@ -82,15 +86,130 @@ public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes,
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern int CFArrayGetTypeID();
+ [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int CFNumberGetTypeID();
+
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr CFDataGetBytePtr(IntPtr theData);
[DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern int CFDataGetLength(IntPtr theData);
+
+ [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
+ public static extern IntPtr CFPreferencesCopyAppValue(IntPtr key, IntPtr appID);
+
+ [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
+ public static extern bool CFNumberGetValue(IntPtr number, CFNumberType theType, out IntPtr valuePtr);
+
+ [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
+ public static extern IntPtr CFDictionaryGetKeysAndValues(IntPtr theDict, IntPtr[] keys, IntPtr[] values);
+
+ [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
+ public static extern long CFDictionaryGetCount(IntPtr theDict);
+
+ public static string CFStringToString(IntPtr cfString)
+ {
+ if (cfString == IntPtr.Zero)
+ {
+ throw new ArgumentNullException(nameof(cfString));
+ }
+
+ if (CFGetTypeID(cfString) != CFStringGetTypeID())
+ {
+ throw new InvalidOperationException("Object is not a CFString.");
+ }
+
+ long length = CFStringGetLength(cfString);
+ IntPtr buffer = Marshal.AllocHGlobal((int)length + 1);
+
+ try
+ {
+ if (!CFStringGetCString(cfString, buffer, length + 1, CFStringEncoding.kCFStringEncodingUTF8))
+ {
+ throw new InvalidOperationException("Failed to convert CFString to C string.");
+ }
+
+ return Marshal.PtrToStringAnsi(buffer);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+
+ public static int CFNumberToInt32(IntPtr cfNumber)
+ {
+ if (cfNumber == IntPtr.Zero)
+ {
+ throw new ArgumentNullException(nameof(cfNumber));
+ }
+
+ if (CFGetTypeID(cfNumber) != CFNumberGetTypeID())
+ {
+ throw new InvalidOperationException("Object is not a CFNumber.");
+ }
+
+ if (!CFNumberGetValue(cfNumber, CFNumberType.kCFNumberIntType, out IntPtr valuePtr))
+ {
+ throw new InvalidOperationException("Failed to convert CFNumber to Int32.");
+ }
+
+ return valuePtr.ToInt32();
+ }
+
+ public static IDictionary CFDictionaryToDictionary(IntPtr cfDict)
+ {
+ if (cfDict == IntPtr.Zero)
+ {
+ throw new ArgumentNullException(nameof(cfDict));
+ }
+
+ if (CFGetTypeID(cfDict) != CFDictionaryGetTypeID())
+ {
+ throw new InvalidOperationException("Object is not a CFDictionary.");
+ }
+
+ int count = (int)CFDictionaryGetCount(cfDict);
+ var keys = new IntPtr[count];
+ var values = new IntPtr[count];
+
+ CFDictionaryGetKeysAndValues(cfDict, keys, values);
+
+ var dict = new Dictionary(capacity: count);
+ for (int i = 0; i < count; i++)
+ {
+ string keyStr = CFStringToString(keys[i])!;
+ string valueStr = CFStringToString(values[i]);
+
+ dict[keyStr] = valueStr;
+ }
+
+ return dict;
+ }
}
public enum CFStringEncoding
{
kCFStringEncodingUTF8 = 0x08000100,
}
+
+ public enum CFNumberType
+ {
+ kCFNumberSInt8Type = 1,
+ kCFNumberSInt16Type = 2,
+ kCFNumberSInt32Type = 3,
+ kCFNumberSInt64Type = 4,
+ kCFNumberFloat32Type = 5,
+ kCFNumberFloat64Type = 6,
+ kCFNumberCharType = 7,
+ kCFNumberShortType = 8,
+ kCFNumberIntType = 9,
+ kCFNumberLongType = 10,
+ kCFNumberLongLongType = 11,
+ kCFNumberFloatType = 12,
+ kCFNumberDoubleType = 13,
+ kCFNumberCFIndexType = 14,
+ kCFNumberNSIntegerType = 15,
+ kCFNumberCGFloatType = 16
+ }
}
diff --git a/src/shared/Core/NullCredentialStore.cs b/src/shared/Core/NullCredentialStore.cs
new file mode 100644
index 000000000..fac92f47c
--- /dev/null
+++ b/src/shared/Core/NullCredentialStore.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+
+namespace GitCredentialManager;
+
+///
+/// Credential store that does nothing. This is useful when you want to disable internal credential storage
+/// and only use another helper configured in Git to store credentials.
+///
+public class NullCredentialStore : ICredentialStore
+{
+ public IList GetAccounts(string service) => Array.Empty();
+
+ public ICredential Get(string service, string account) => null;
+
+ public void AddOrUpdate(string service, string account, string secret) { }
+
+ public bool Remove(string service, string account) => false;
+}
diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs
index 2aa71edf4..0e24ce9a3 100644
--- a/src/shared/Core/Settings.cs
+++ b/src/shared/Core/Settings.cs
@@ -189,6 +189,11 @@ public interface ISettings : IDisposable
///
bool UseSoftwareRendering { get; }
+ ///
+ /// Permit the use of unsafe remotes URLs such as regular HTTP.
+ ///
+ bool AllowUnsafeRemotes { get; }
+
///
/// Get TRACE2 settings.
///
@@ -580,6 +585,12 @@ public bool UseSoftwareRendering
}
}
+ public bool AllowUnsafeRemotes =>
+ TryGetSetting(KnownEnvars.GcmAllowUnsafeRemotes,
+ KnownGitCfg.Credential.SectionName,
+ KnownGitCfg.Credential.AllowUnsafeRemotes,
+ out string str) && str.ToBooleanyOrDefault(false);
+
public Trace2Settings GetTrace2Settings()
{
var settings = new Trace2Settings();
diff --git a/src/shared/Core/StandardStreams.cs b/src/shared/Core/StandardStreams.cs
index d0b3042b0..45f9f6cc7 100644
--- a/src/shared/Core/StandardStreams.cs
+++ b/src/shared/Core/StandardStreams.cs
@@ -39,7 +39,7 @@ public TextReader In
{
if (_stdIn == null)
{
- _stdIn = new StreamReader(Console.OpenStandardInput(), EncodingEx.UTF8NoBom);
+ _stdIn = new GitStreamReader(Console.OpenStandardInput(), EncodingEx.UTF8NoBom);
}
return _stdIn;
diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj
index 2b594e3eb..456adf547 100644
--- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj
+++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj
@@ -4,7 +4,7 @@
Exe
net8.0
net472;net8.0
- win-x86;osx-x64;linux-x64;osx-arm64
+ win-x86;osx-x64;linux-x64;osx-arm64;linux-arm64;linux-arm
x86
git-credential-manager
GitCredentialManager
diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs
index 918e859a0..21d29f651 100644
--- a/src/shared/GitHub/GitHubHostProvider.cs
+++ b/src/shared/GitHub/GitHubHostProvider.cs
@@ -285,10 +285,13 @@ public virtual Task EraseCredentialAsync(InputArguments input)
ThrowIfDisposed();
// We should not allow unencrypted communication and should inform the user
- if (StringComparer.OrdinalIgnoreCase.Equals(remoteUri.Scheme, "http"))
+ if (!_context.Settings.AllowUnsafeRemotes &&
+ StringComparer.OrdinalIgnoreCase.Equals(remoteUri.Scheme, "http"))
{
throw new Trace2Exception(_context.Trace2,
- "Unencrypted HTTP is not supported for GitHub. Ensure the repository remote URL is using HTTPS.");
+ "Unencrypted HTTP is not recommended for GitHub. " +
+ "Ensure the repository remote URL is using HTTPS " +
+ $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes.");
}
string service = GetServiceName(remoteUri);
diff --git a/src/shared/GitLab/GitLabConstants.cs b/src/shared/GitLab/GitLabConstants.cs
index a686ece7a..69f1f9b9e 100644
--- a/src/shared/GitLab/GitLabConstants.cs
+++ b/src/shared/GitLab/GitLabConstants.cs
@@ -10,7 +10,6 @@ public static class GitLabConstants
// Owned by https://gitlab.com/gitcredentialmanager
public const string OAuthClientId = "172b9f227872b5dde33f4d9b1db06a6a5515ae79508e7a00c973c85ce490671e";
- public const string OAuthClientSecret = "7da92770d1447508601e4ba026bc5eb655c8268e818cd609889cc9bae2023f39";
public static readonly Uri OAuthRedirectUri = new Uri("http://127.0.0.1/");
// https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow
diff --git a/src/shared/GitLab/GitLabHostProvider.cs b/src/shared/GitLab/GitLabHostProvider.cs
index eda6e2f0f..6cda3c0e1 100644
--- a/src/shared/GitLab/GitLabHostProvider.cs
+++ b/src/shared/GitLab/GitLabHostProvider.cs
@@ -95,10 +95,13 @@ public override async Task GenerateCredentialAsync(InputArguments i
ThrowIfDisposed();
// We should not allow unencrypted communication and should inform the user
- if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"))
+ if (!Context.Settings.AllowUnsafeRemotes &&
+ StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"))
{
throw new Trace2Exception(Context.Trace2,
- "Unencrypted HTTP is not supported for GitHub. Ensure the repository remote URL is using HTTPS.");
+ "Unencrypted HTTP is not recommended for GitLab. " +
+ "Ensure the repository remote URL is using HTTPS " +
+ $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes.");
}
Uri remoteUri = input.GetRemoteUri();
diff --git a/src/shared/GitLab/GitLabOAuth2Client.cs b/src/shared/GitLab/GitLabOAuth2Client.cs
index 3b146aaeb..ba72f5b41 100644
--- a/src/shared/GitLab/GitLabOAuth2Client.cs
+++ b/src/shared/GitLab/GitLabOAuth2Client.cs
@@ -59,7 +59,8 @@ private static string GetClientSecret(ISettings settings)
return clientSecret;
}
- return GitLabConstants.OAuthClientSecret;
+ // no secret necessary
+ return null;
}
}
}
diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
index 1d5c649d0..525704886 100644
--- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
+++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
@@ -59,7 +59,7 @@ public bool IsSupported(InputArguments input)
return false;
}
- // We do not support unencrypted HTTP communications to Azure Repos,
+ // We do not recommend unencrypted HTTP communications to Azure Repos,
// but we report `true` here for HTTP so that we can show a helpful
// error message for the user in `CreateCredentialAsync`.
return input.TryGetHostAndPort(out string hostName, out _)
@@ -208,16 +208,22 @@ protected override void ReleaseManagedResources()
base.ReleaseManagedResources();
}
- private async Task GeneratePersonalAccessTokenAsync(InputArguments input)
+ private void ThrowIfUnsafeRemote(InputArguments input)
{
- ThrowIfDisposed();
-
- // We should not allow unencrypted communication and should inform the user
- if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"))
+ if (!_context.Settings.AllowUnsafeRemotes &&
+ StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"))
{
throw new Trace2Exception(_context.Trace2,
- "Unencrypted HTTP is not supported for Azure Repos. Ensure the repository remote URL is using HTTPS.");
+ "Unencrypted HTTP is not recommended for Azure Repos. " +
+ "Ensure the repository remote URL is using HTTPS " +
+ $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes.");
}
+ }
+
+ private async Task GeneratePersonalAccessTokenAsync(InputArguments input)
+ {
+ ThrowIfDisposed();
+ ThrowIfUnsafeRemote(input);
Uri remoteUserUri = input.GetRemoteUri(includeUser: true);
Uri orgUri = UriHelpers.CreateOrganizationUri(remoteUserUri, out _);
@@ -257,16 +263,11 @@ private async Task GeneratePersonalAccessTokenAsync(InputArguments
private async Task GetAzureAccessTokenAsync(InputArguments input)
{
+ ThrowIfUnsafeRemote(input);
+
Uri remoteWithUserUri = input.GetRemoteUri(includeUser: true);
string userName = input.UserName;
- // We should not allow unencrypted communication and should inform the user
- if (StringComparer.OrdinalIgnoreCase.Equals(remoteWithUserUri.Scheme, "http"))
- {
- throw new Trace2Exception(_context.Trace2,
- "Unencrypted HTTP is not supported for Azure Repos. Ensure the repository remote URL is using HTTPS.");
- }
-
Uri orgUri = UriHelpers.CreateOrganizationUri(remoteWithUserUri, out string orgName);
_context.Trace.WriteLine($"Determining Microsoft Authentication authority for Azure DevOps organization '{orgName}'...");
diff --git a/src/shared/TestInfrastructure/Objects/TestSettings.cs b/src/shared/TestInfrastructure/Objects/TestSettings.cs
index f14bf6cc9..3e67e39b0 100644
--- a/src/shared/TestInfrastructure/Objects/TestSettings.cs
+++ b/src/shared/TestInfrastructure/Objects/TestSettings.cs
@@ -53,6 +53,8 @@ public class TestSettings : ISettings
public bool UseMsAuthDefaultAccount { get; set; }
+ public bool AllowUnsafeRemotes { get; set; } = false;
+
public Trace2Settings GetTrace2Settings()
{
return new Trace2Settings()
@@ -189,6 +191,8 @@ ProxyConfiguration ISettings.GetProxyConfiguration()
bool ISettings.UseSoftwareRendering => false;
+ bool ISettings.AllowUnsafeRemotes => AllowUnsafeRemotes;
+
#endregion
#region IDisposable
diff --git a/src/shared/TestInfrastructure/TestUtils.cs b/src/shared/TestInfrastructure/TestUtils.cs
index c547856d7..000b8e75e 100644
--- a/src/shared/TestInfrastructure/TestUtils.cs
+++ b/src/shared/TestInfrastructure/TestUtils.cs
@@ -1,5 +1,7 @@
using System;
+using System.Diagnostics;
using System.IO;
+using System.Threading.Tasks;
namespace GitCredentialManager.Tests
{
@@ -87,5 +89,41 @@ public static string GetUuid(int length = -1)
return uuid.Substring(0, length);
}
+
+ public static async Task RunCommandAsync(string filePath, string arguments, string workingDirectory = null)
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = filePath,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory
+ }
+ };
+
+ process.Start();
+
+ string output = await process.StandardOutput.ReadToEndAsync();
+ string error = await process.StandardError.ReadToEndAsync();
+
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Command `{filePath} {arguments}` failed with exit code {process.ExitCode}." +
+ Environment.NewLine +
+ $"Output: {output}" +
+ Environment.NewLine +
+ $"Error: {error}");
+ }
+
+ return output;
+ }
}
}