diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 745027d8b..9f8170d53 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.2.0 with: dotnet-version: 8.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 27834c10e..b8da48e7b 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.2.0 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.2.0 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.2.0 with: dotnet-version: 8.0.x diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 6f153d5f6..d3a1f777e 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -1,17 +1,12 @@ -name: "Lint documentation" -on: - workflow_dispatch: - push: - branches: [ main, linux ] - paths: - - '**.md' - - '.github/workflows/lint-docs.yml' - pull_request: - branches: [ main, linux ] + + + + paths: - - '**.md' - - '.github/workflows/lint-docs.yml' + + + pull jobs: lint-markdown: @@ -20,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: DavidAnson/markdownlint-cli2-action@b4c9feab76d8025d1e83c653fa3990936df0e6c8 + - uses: DavidAnson/markdownlint-cli2-action@eb5ca3ab411449c66620fe7f1b3c9e10547144b0 with: globs: | "**/*.md" @@ -35,7 +30,7 @@ jobs: - name: Run link checker # For any troubleshooting, see: # https://github.com/lycheeverse/lychee/blob/master/docs/TROUBLESHOOTING.md - uses: lycheeverse/lychee-action@2b973e86fc7b1f6b36a93795fe2c9c6ae1118621 + with: # user-agent: if a user agent is not specified, some websites (e.g. @@ -54,3 +49,4 @@ jobs: # no extra permissions is enough to be able to check public repos # See: https://github.com/lycheeverse/lychee#github-token GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} +.github/workflows/lint-docs.yml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f578e109..c8b32151f 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.2.0 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.2.0 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.2.0 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.2.0 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.2.0 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.2.0 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.2.0 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.2.0 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..6c6aa1535 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]. 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/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/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; + } } }