diff --git a/.github/run_esrp_signing.py b/.github/run_esrp_signing.py new file mode 100644 index 000000000..223950767 --- /dev/null +++ b/.github/run_esrp_signing.py @@ -0,0 +1,112 @@ +import json +import os +import glob +import pprint +import subprocess +import sys + +esrp_tool = os.path.join("esrp", "tools", "EsrpClient.exe") + +aad_id = os.environ['AZURE_AAD_ID'].strip() +workspace = os.environ['GITHUB_WORKSPACE'].strip() + +source_root_location = os.path.join(workspace, "deb", "Release") +destination_location = os.path.join(workspace) + +files = glob.glob(os.path.join(source_root_location, "*.deb")) + +print("Found files:") +pprint.pp(files) + +if len(files) < 1 or not files[0].endswith(".deb"): + print("Error: cannot find .deb to sign") + exit(1) + +file_to_sign = os.path.basename(files[0]) + +auth_json = { + "Version": "1.0.0", + "AuthenticationType": "AAD_CERT", + "TenantId": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "ClientId": aad_id, + "AuthCert": { + "SubjectName": f"CN={aad_id}.microsoft.com", + "StoreLocation": "LocalMachine", + "StoreName": "My", + }, + "RequestSigningCert": { + "SubjectName": f"CN={aad_id}", + "StoreLocation": "LocalMachine", + "StoreName": "My", + } +} + +input_json = { + "Version": "1.0.0", + "SignBatches": [ + { + "SourceLocationType": "UNC", + "SourceRootDirectory": source_root_location, + "DestinationLocationType": "UNC", + "DestinationRootDirectory": destination_location, + "SignRequestFiles": [ + { + "CustomerCorrelationId": "01A7F55F-6CDD-4123-B255-77E6F212CDAD", + "SourceLocation": file_to_sign, + "DestinationLocation": os.path.join("Signed", file_to_sign), + } + ], + "SigningInfo": { + "Operations": [ + { + "KeyCode": "CP-450779-Pgp", + "OperationCode": "LinuxSign", + "Parameters": {}, + "ToolName": "sign", + "ToolVersion": "1.0", + } + ] + } + } + ] +} + +policy_json = { + "Version": "1.0.0", + "Intent": "production release", + "ContentType": "Debian package", +} + +configs = [ + ("auth.json", auth_json), + ("input.json", input_json), + ("policy.json", policy_json), +] + +for filename, data in configs: + with open(filename, 'w') as fp: + json.dump(data, fp) + +# Run ESRP Client +esrp_out = "esrp_out.json" +result = subprocess.run( + [esrp_tool, "sign", + "-a", "auth.json", + "-i", "input.json", + "-p", "policy.json", + "-o", esrp_out, + "-l", "Verbose"], + cwd=workspace) + +if result.returncode != 0: + print("Failed to run ESRPClient.exe") + sys.exit(1) + +if os.path.isfile(esrp_out): + print("ESRP output json:") + with open(esrp_out, 'r') as fp: + pprint.pp(json.load(fp)) + +signed_file = os.path.join(destination_location, "Signed", file_to_sign) +if os.path.isfile(signed_file): + print(f"Success!\nSigned {signed_file}") diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml index c202fbf64..71336f45a 100644 --- a/.github/workflows/build-installers.yml +++ b/.github/workflows/build-installers.yml @@ -1,6 +1,7 @@ name: Build-Installers on: + workflow_dispatch: push: branches: [ master, release ] pull_request: diff --git a/.github/workflows/build-signed-deb.yml b/.github/workflows/build-signed-deb.yml new file mode 100644 index 000000000..3373c072b --- /dev/null +++ b/.github/workflows/build-signed-deb.yml @@ -0,0 +1,93 @@ +name: "Build Signed Debian Installer" + +on: + workflow_dispatch: + release: + types: [released] + +jobs: + build: + name: "Build" + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Indicate full history so Nerdbank.GitVersioning works. + + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 3.1.302 + + - name: Install dependencies + run: dotnet restore --force + + - name: Build Linux Payloads + run: dotnet build -c Release src/linux/Packaging.Linux/Packaging.Linux.csproj + + - name: Upload Installers + uses: actions/upload-artifact@v2 + with: + name: LinuxInstallers + path: | + out/linux/Packaging.Linux/deb/Release/*.deb + out/linux/Packaging.Linux/tar/Release/*.tar.gz + + sign: + name: 'Sign' + runs-on: windows-latest + needs: build + steps: + - name: setup python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - uses: actions/checkout@v2 + + - name: 'Download Installer Artifact' + uses: actions/download-artifact@v2 + with: + name: LinuxInstallers + + - uses: Azure/login@v1.1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: 'Install ESRP Client' + shell: pwsh + env: + AZ_SUB: ${{ secrets.AZURE_SUBSCRIPTION }} + run: | + az storage blob download --subscription "$env:AZ_SUB" --account-name gitcitoolstore -c tools -n microsoft.esrpclient.1.2.47.nupkg -f esrp.zip + Expand-Archive -Path esrp.zip -DestinationPath .\esrp + + - name: Install Certs + shell: pwsh + env: + AZ_SUB: ${{ secrets.AZURE_SUBSCRIPTION }} + AZ_VAULT: ${{ secrets.AZURE_VAULT }} + SSL_CERT: ${{ secrets.VAULT_SSL_CERT_NAME }} + ESRP_CERT: ${{ secrets.VAULT_ESRP_CERT_NAME }} + run: | + az keyvault secret download --subscription "$env:AZ_SUB" --vault-name "$env:AZ_VAULT" --name "$env:SSL_CERT" -f out.pfx + certutil -f -importpfx out.pfx + Remove-Item out.pfx + + az keyvault secret download --subscription "$env:AZ_SUB" --vault-name "$env:AZ_VAULT" --name "$env:ESRP_CERT" -f out.pfx + certutil -f -importpfx out.pfx + Remove-Item out.pfx + + - name: Run ESRP Client + shell: pwsh + env: + AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} + run: | + python .github/run_esrp_signing.py + + - name: Upload Installer + uses: actions/upload-artifact@v2 + with: + name: DebianInstallerSigned + path: | + Signed/*.deb \ No newline at end of file diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 7cec30fc9..bffee0884 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -1,6 +1,7 @@ name: GCM-Core on: + workflow_dispatch: push: branches: [ master, linux ] pull_request: diff --git a/.github/workflows/release-winget.yaml b/.github/workflows/release-winget.yaml new file mode 100644 index 000000000..552f62970 --- /dev/null +++ b/.github/workflows/release-winget.yaml @@ -0,0 +1,32 @@ +name: "release-winget" +on: + release: + types: [released] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Update winget repository + uses: mjcheetham/update-winget@v1.0 + with: + token: ${{ secrets.WINGET_TOKEN }} + repo: microsoft/winget-pkgs + id: Microsoft.GitCredentialManagerCore + releaseAsset: gcmcore-win-x86-(.*)\.exe + manifestText: | + Id: {{id}} + Version: {{version}} + Name: Git Credential Manager Core + Publisher: Microsoft Corporation + AppMoniker: git-credential-manager-core + Homepage: https://aka.ms/gcmcore + Tags: "gcm, gcmcore, git, credential" + License: Copyright (C) Microsoft Corporation + Description: Secure, cross-platform Git credential storage with authentication to GitHub, Azure Repos, and other popular Git hosting services. + Installers: + - Arch: x86 + Url: {{url}} + InstallerType: Inno + Sha256: {{sha256}} + alwaysUsePullRequest: true diff --git a/.gitignore b/.gitignore index 2a2c85213..1b6aff9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -340,3 +340,7 @@ out/ # dotnet local tools .tools/ + +# Signing generated Files +auth.json +input.json \ No newline at end of file diff --git a/README.md b/README.md index c23bebec1..8708dcb5c 100644 --- a/README.md +++ b/README.md @@ -6,36 +6,39 @@ master|[![Build Status](https://mseng.visualstudio.com/AzureDevOps/_apis/build/s --- -[Git Credential Manager Core](https://github.com/Microsoft/Git-Credential-Manager-Core) (GCM Core) is a secure Git credential helper built on [.NET Core](https://microsoft.com/dotnet) that runs on Windows and macOS. Linux support is planned, but not yet scheduled. +[Git Credential Manager Core](https://github.com/microsoft/Git-Credential-Manager-Core) (GCM Core) is a secure Git credential helper built on [.NET Core](https://microsoft.com/dotnet) that runs on Windows and macOS. Linux support is in an early preview. Compared to Git's [built-in credential helpers]((https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage)) (Windows: wincred, macOS: osxkeychain, Linux: gnome-keyring) which provides single-factor authentication support working on any HTTP-enabled Git repository, GCM Core provides multi-factor authentication support for [Azure DevOps](https://dev.azure.com/), Azure DevOps Server (formerly Team Foundation Server), GitHub, and Bitbucket. -## Public preview +Git Credential Manager Core (GCM Core) replaces the .NET Framework-based [Git Credential Manager for Windows](https://github.com/microsoft/Git-Credential-Manager-for-Windows) (GCM), and the Java-based [Git Credential Manager for Mac and Linux](https://github.com/microsoft/Git-Credential-Manager-for-Mac-and-Linux) (Java GCM), providing a consistent authentication experience across all platforms. -The long-term goal of Git Credential Manager Core (GCM Core) is to converge the .NET Framework-based [Git Credential Manager for Windows](https://github.com/Microsoft/Git-Credential-Manager-for-Windows) (GCM), and the Java-based [Git Credential Manager for Mac and Linux](https://github.com/Microsoft/Git-Credential-Manager-for-Mac-and-Linux) (Java GCM), providing a consistent authentication experience across all platforms. +## Current status -### Current status +Git Credential Manager Core is currently available for macOS and Windows, with Linux support in preview. If the Linux version of GCM Core is insufficient then SSH still remains an option: -Git Credential Manager Core is currently in preview for macOS and Windows. Linux support is planned, but not yet scheduled. For now, we recommend [SSH for authentication to Azure DevOps](https://docs.microsoft.com/en-us/azure/devops/repos/git/use-ssh-keys-to-authenticate?view=azure-devops) for Linux users. +- [Azure DevOps SSH](https://docs.microsoft.com/en-us/azure/devops/repos/git/use-ssh-keys-to-authenticate?view=azure-devops) +- [GitHub SSH](https://help.github.com/en/articles/connecting-to-github-with-ssh) +- [Bitbucket SSH](https://confluence.atlassian.com/bitbucket/ssh-keys-935365775.html) Feature|Windows|macOS|Linux -|:-:|:-:|:-: -Installer/uninstaller|✓|✓| -Secure platform credential storage|✓
Windows Credential Manager|✓
macOS Keychain| +Installer/uninstaller|✓|✓|✓\*\* +Secure platform credential storage|✓
Windows
Credential
Manager|✓
macOS Keychain|✓
1. Secret Service
2. `pass`/GPG
3. Plaintext files Multi-factor authentication support for Azure DevOps|✓|✓|✓\* Two-factor authentication support for GitHub|✓|✓\*|✓\* Two-factor authentication support for Bitbucket|✓|✓\*|✓\* Windows Integrated Authentication (NTLM/Kerberos) support|✓|_N/A_|_N/A_ Basic HTTP authentication support|✓|✓|✓ -Proxy support|✓|✓| +Proxy support|✓|✓|✓ **Notes:** (\*) Currently only supported when using Git from the terminal or command line. A platform-native UI experience is not yet available, but planned. +(\*\*) Debian package offered but not yet available on an official Microsoft feed. + ### Planned features -- [ ] Linux support ([#135](https://github.com/microsoft/Git-Credential-Manager-Core/issues/135)) - [ ] macOS/Linux native UI ([#136](https://github.com/microsoft/Git-Credential-Manager-Core/issues/136)) ## Download and Install @@ -51,6 +54,12 @@ brew tap microsoft/git brew cask install git-credential-manager-core ``` +After installing you can stay up-to-date with new releases by running: + +```shell +brew upgrade git-credential-manager-core +``` + #### Git Credential Manager for Mac and Linux (Java-based GCM) If you have an existing installation of the 'Java GCM' on macOS and you have installed this using Homebrew, this installation will be unlinked (`brew unlink git-credential-manager`) when GCM Core is installed. @@ -67,7 +76,7 @@ brew cask uninstall git-credential-manager-core ### macOS Package -We also provide a [.pkg installer](https://github.com/Microsoft/Git-Credential-Manager-Core/releases/latest) with each release. To install, double-click the installation package and follow the instructions presented. +We also provide a [.pkg installer](https://github.com/microsoft/Git-Credential-Manager-Core/releases/latest) with each release. To install, double-click the installation package and follow the instructions presented. #### Uninstall @@ -79,9 +88,33 @@ sudo /usr/local/share/gcm-core/uninstall.sh --- +### Linux Debian package (.deb) + +Download the latest [.deb package](https://github.com/microsoft/Git-Credential-Manager-Core/releases/latest), and run the following: + +```shell +sudo dpkg -i +git-credential-manager-core configure +``` + +Note that Linux distributions [require additional configuration](https://aka.ms/gcmcore-linuxcredstores) to use GCM Core. + +--- + +### Linux tarball (.tar.gz) + +Download the latest [tarball](https://github.com/microsoft/Git-Credential-Manager-Core/releases/latest), and run the following: + +```shell +tar -xvf -C /usr/local/bin +git-credential-manager-core configure +``` + +--- + ### Windows -You can download the [latest installer](https://github.com/Microsoft/Git-Credential-Manager-Core/releases/latest) for Windows. To install, double-click the installation package and follow the instructions presented. +You can download the [latest installer](https://github.com/microsoft/Git-Credential-Manager-Core/releases/latest) for Windows. To install, double-click the installation package and follow the instructions presented. #### Git Credential Manager for Windows diff --git a/docs/development.md b/docs/development.md index 7103be288..6db8a219a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -41,7 +41,17 @@ The flat binaries can also be found in `out\windows\Payload.Windows\bin\Debug\ne ### Linux -_No information yet._ +The two available solution configurations are `LinuxDebug` and `LinuxRelease`. + +To build from the command line, run: + +```shell +dotnet build -c LinuxDebug +``` + +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`. ## Debugging diff --git a/docs/faq.md b/docs/faq.md index 5f2cb2a72..4ec8bfa27 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -24,6 +24,10 @@ Please make sure your remote URLs use "https://" rather than "http://". You probably need to configure Git and GCM Core to use a proxy. Please see detailed information [here](https://aka.ms/gcmcore-httpproxy). +### Q: I'm getting errors about picking a credential store on Linux. + +On Linux you must [select and configure a credential store](https://aka.ms/gcmcore-linuxcredstores), as due to the varied nature of distributions and installations, we cannot guarantee a suitable storage solution is available. + ## About the project ### Q: How does this project relate to [Git Credential Manager for Windows](https://github.com/Microsoft/Git-Credential-Manager-for-Windows) and [Git Credential Manager for Mac and Linux](https://github.com/Microsoft/Git-Credential-Manager-for-Mac-and-Linux)? @@ -35,13 +39,15 @@ Git Credential Manager Core (GCM Core; this project) aims to replace both GCM Wi ### Q: Does this mean GCM for Windows (.NET Framework-based) is deprecated? -No. Git Credential Manager for Windows (GCM Windows) will continue to be supported until such a time that GCM Core is a complete replacement. +Yes. Git Credential Manager for Windows (GCM Windows) is no longer receiving updates and fixes. All development effort has now been directed to GCM Core. GCM Core is available as an credential helper option in Git for Windows 2.28, and will be made the default helper in 2.29. ### Q: Does this mean the Java-based GCM for Mac/Linux is deprecated? -Yes. Usage of Git Credential Manager for Mac and Linux (Java GCM) should be replaced with SSH keys. If you wish to take part in the public preview of GCM Core on macOS please feel free to install the latest preview release and give feedback! Otherwise, using SSH would be preferred on macOS and Linux to Java GCM. +Yes. Usage of Git Credential Manager for Mac and Linux (Java GCM) should be replaced with GCM Core or SSH keys. If you wish to install GCM Core on macOS or Linux, please follow the [download and installation instructions](../README.md#download-and-install). + +### Q: I want to use SSH -SSH configuration instructions: +GCM Core is for HTTPS only. To use SSH please follow the below links: - [Azure DevOps](https://docs.microsoft.com/en-us/azure/devops/repos/git/use-ssh-keys-to-authenticate?view=azure-devops) - [GitHub](https://help.github.com/en/articles/connecting-to-github-with-ssh) @@ -51,9 +57,9 @@ SSH configuration instructions: GCM Windows was not designed with a cross-platform architecture. -### What level of support does GCM Core have during the public preview? +### What level of support does GCM Core have? -Support will be best-effort. We would really appreciate your feedback as we work to make this a great experience across each platform we support. However, for mission critical applications, please use GCM for Windows on Windows or SSH on Mac and Linux. +Support will be best-effort. We would really appreciate your feedback to make this a great experience across each platform we support. ### Q: Why does GCM Core not support operating system/distribution 'X', or Git hosting provider 'Y'? diff --git a/docs/github-apideprecation.md b/docs/github-apideprecation.md new file mode 100644 index 000000000..4023ebef7 --- /dev/null +++ b/docs/github-apideprecation.md @@ -0,0 +1,120 @@ +# GitHub Authentication Deprecation + +## What's going on? + +GitHub now [requires token-based authentication](https://github.blog/2020-07-30-token-authentication-requirements-for-api-and-git-operations/) to +call their APIs, and in the future, use Git itself. + +This means Git credential helpers such as [Git Credential Manager (GCM) for +Windows](https://github.com/microsoft/Git-Credential-Manager-for-Windows), and +old versions of [GCM Core](https://aka.ms/gcmcore) that offer username/password +flows **will not be able to create new access tokens** for accessing Git +repositories. + +If you already have tokens generated by Git credential helpers like GCM for +Windows, they will continue to work until they expire or are revoked/deleted. + +## What should I do now? + +### Windows command-line users + +The best thing to do right now is upgrade to the latest Git for Windows (at +least version 2.29), which includes a version of Git Credential Manager Core that +uses supported OAuth token-based authentication. + +[Download the latest Git for Windows ⬇️](https://git-scm.com/download/win) + +### Visual Studio users + +Please update to the latest supported release of Visual Studio, that includes +GCM Core and support for OAuth token-based authentication. + +- [Visual Studio 2019 ⬇️](https://docs.microsoft.com/en-us/visualstudio/install/update-visual-studio?view=vs-2019) +- [Visual Studio 2017 ⬇️](https://docs.microsoft.com/en-us/visualstudio/install/update-visual-studio?view=vs-2017) + +### SSH, macOS, and Linux users + +If you are using SSH this change does **not** affect you. + +If you are using an older version of Git Credential Manager Core (before +2.0.124-beta) please upgrade to the latest version following [these +instructions](https://github.com/microsoft/Git-Credential-Manager-Core#download-and-install). + +## What if I cannot upgrade Git for Windows? + +If you are unable to upgrade Git for Windows, you can manually install Git +Credential Manager Core as a standalone install. This will override the older, +GCM for Windows bundled with the Git for Windows installation. + +[Download Git Credential Manager Core standalone ⬇️](https://aka.ms/gcmcore-latest) + +## What if I cannot use Git Credential Manager Core? + +If you are unable to use Git Credential Manager Core due to a bug or +compatibility issue we'd [like to know why](https://github.com/microsoft/Git-Credential-Manager-Core/issues/new/choose)! + +## Help! I cannot make any changes to my Windows machine without an Administrator! + +If you do not have permission to change your installation (for example in a +corporate environment) there is a workaround which should work and does not +require administrator permissions. + +0. Tell your system administrator they should start planning to upgrade the + installed version of Git for Windows to at least 2.29! 😁 + +1. [Create a new personal access token](https://github.com/settings/tokens/new?scopes=repo,gist,workflow) (see official [documentation](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token)) + +2. Enter a name ("note") for the token and ensure the `repo`, `gist`, and + `workflow` scopes are selected: +![image](https://user-images.githubusercontent.com/5658207/95448332-1beb2000-095b-11eb-9a48-9c05b1926a6b.png) +... +![image](https://user-images.githubusercontent.com/5658207/95447304-6f5c6e80-0959-11eb-924b-50b86c2b3d77.png) +... +![image](https://user-images.githubusercontent.com/5658207/95447450-a3d02a80-0959-11eb-82a8-2d2834d5aa16.png) +... +![image](https://user-images.githubusercontent.com/5658207/95447343-7b483080-0959-11eb-8e00-151d53893f3f.png) + +3. Click "Generate Token" + +![image](https://user-images.githubusercontent.com/5658207/95448393-31f8e080-095b-11eb-9568-cfd1c567a65c.png) + +4. **[IMPORTANT]** Keep the resulting page open as this contains your new token + (this will only be displayed once!) + +![image](https://user-images.githubusercontent.com/5658207/95448288-ff4ee800-095a-11eb-9709-8e37bde8b716.png) + +5. Save the generated PAT in the Windows Credential Manager: + + 1. If you prefer to use the command-line, open a command prompt (cmd.exe) and + type the following: + + ```bash + cmdkey /generic:git:https://github.com /user:PersonalAccessToken /pass + ``` + + You will be prompted to enter a password – copy the newly generated PAT in + step 4 and paste it here, and press Enter + + ![image](https://user-images.githubusercontent.com/5658207/95448479-4fc64580-095b-11eb-9970-0b6faf7f4ae7.png) + + 1. If you do not wish to use the command-line, [open the Credential Manager + via Control Panel](https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0) + and select the "Windows Credentials" tab. + + ![image](https://user-images.githubusercontent.com/5658207/96468389-f6e09200-1223-11eb-9993-ae7b4096b769.png) + + Click "Add a generic credential", and enter the following details: + + - Internet or network address: `git:https://github.com` + - Username: `PersonalAccessToken` + - Password: _(copy and paste the PAT generated in step 4 here)_ + + ![image](https://user-images.githubusercontent.com/5658207/96468318-ddd7e100-1223-11eb-8cd4-aa118493c538.png) + +## What about GitHub Enterprise Server (GHES)? + +As mentioned in [the blog post](https://github.blog/2020-07-30-token-authentication-requirements-for-api-and-git-operations/), +the new token-based authentication requirements **DO NOT** apply to GHES: + +> We have not announced any changes to GitHub Enterprise Server, which remains +> unaffected at this time. diff --git a/docs/usage.md b/docs/usage.md index d41c93eca..fb0b260f2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,15 +6,27 @@ GCM Core stays invisible as much as possible, so ideally you’ll forget that yo Assuming GCM Core has been installed, use your favorite terminal to execute the following commands to interact directly with GCM. ```shell -git credential-manager [ []] +git credential-manager-core [ []] ``` ## Commands -### version +### help / --help + +Displays a list of available commands. + +### version / --version Displays the current version. ### get / store / erase Commands for interaction with Git. You shouldn't need to run these manually. + +Read the [Git manual](https://git-scm.com/docs/gitcredentials#_custom_helpers) about custom helpers for more information. + +### configure/unconfigure + +Set your user-level Git configuration (`~/.gitconfig`) to use GCM Core. If you pass +`--system` to these commands, they act on the system-level Git configuration +(`/etc/gitconfig`) instead. diff --git a/src/osx/Installer.Mac/build.sh b/src/osx/Installer.Mac/build.sh index 466eb5f6e..ba5308da1 100755 --- a/src/osx/Installer.Mac/build.sh +++ b/src/osx/Installer.Mac/build.sh @@ -38,11 +38,15 @@ if [ -z "$VERSION" ]; then die "--version was not set" fi -PAYLOAD="$INSTALLER_OUT/pkg/$CONFIGURATION/payload" -PKGOUT="$INSTALLER_OUT/pkg/$CONFIGURATION/gcmcore-osx-$VERSION.pkg" +OUTDIR="$INSTALLER_OUT/pkg/$CONFIGURATION" +PAYLOAD="$OUTDIR/payload" +COMPONENTDIR="$OUTDIR/components" +COMPONENTOUT="$COMPONENTDIR/com.microsoft.gitcredentialmanager.component.pkg" +DISTOUT="$OUTDIR/gcmcore-osx-$VERSION.pkg" # Layout and pack "$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" --output="$PAYLOAD" || exit 1 -"$INSTALLER_SRC/pack.sh" --payload="$PAYLOAD" --version="$VERSION" --output="$PKGOUT" || exit 1 +"$INSTALLER_SRC/pack.sh" --payload="$PAYLOAD" --version="$VERSION" --output="$COMPONENTOUT" || exit 1 +"$INSTALLER_SRC/dist.sh" --package-path="$COMPONENTDIR" --version="$VERSION" --output="$DISTOUT" || exit 1 echo "Build of Installer.Mac complete." diff --git a/src/osx/Installer.Mac/dist.sh b/src/osx/Installer.Mac/dist.sh new file mode 100755 index 000000000..749231583 --- /dev/null +++ b/src/osx/Installer.Mac/dist.sh @@ -0,0 +1,73 @@ +#!/bin/bash +die () { + echo "$*" >&2 + exit 1 +} + +# Directories +THISDIR="$( cd "$(dirname "$0")" ; pwd -P )" +ROOT="$( cd "$THISDIR"/../../.. ; pwd -P )" +SRC="$ROOT/src" +OUT="$ROOT/out" +INSTALLER_SRC="$SRC/osx/Installer.Mac" +RESXPATH="$INSTALLER_SRC/resources" +DISTPATH="$INSTALLER_SRC/distribution.xml" + +# Product information +IDENTIFIER="com.microsoft.gitcredentialmanager.dist" + +# Parse script arguments +for i in "$@" +do +case "$i" in + --version=*) + VERSION="${i#*=}" + shift # past argument=value + ;; + --package-path=*) + PACKAGEPATH="${i#*=}" + shift # past argument=value + ;; + --output=*) + DISTOUT="${i#*=}" + shift # past argument=value + ;; + *) + # unknown option + ;; +esac +done + +# Perform pre-execution checks +if [ -z "$VERSION" ]; then + die "--version was not set" +fi +if [ -z "$PACKAGEPATH" ]; then + die "--package-path was not set" +elif [ ! -d "$PACKAGEPATH" ]; then + die "Could not find '$PACKAGEPATH'. Did you run pack.sh first?" +fi +if [ -z "$DISTOUT" ]; then + die "--output was not set" +fi + +# Cleanup any old package +if [ -e "$DISTOUT" ]; then + echo "Deleteing old product package '$DISTOUT'..." + rm "$DISTOUT" +fi + +# Ensure the parent directory for the package exists +mkdir -p "$(dirname "$DISTOUT")" + +# Build product installer +echo "Building product package..." +/usr/bin/productbuild \ + --package-path "$PACKAGEPATH" \ + --resources "$RESXPATH" \ + --distribution "$DISTPATH" \ + --identifier "$IDENTIFIER" \ + --version "$VERSION" \ + "$DISTOUT" || exit 1 + +echo "Product build complete." diff --git a/src/osx/Installer.Mac/distribution.xml b/src/osx/Installer.Mac/distribution.xml new file mode 100644 index 000000000..4bf9b9fb6 --- /dev/null +++ b/src/osx/Installer.Mac/distribution.xml @@ -0,0 +1,21 @@ + + + Git Credential Manager Core + + + + + + + + + + + + + + + + com.microsoft.gitcredentialmanager.component.pkg + + diff --git a/src/osx/Installer.Mac/pack.sh b/src/osx/Installer.Mac/pack.sh index e795ae313..b58f4ce5a 100755 --- a/src/osx/Installer.Mac/pack.sh +++ b/src/osx/Installer.Mac/pack.sh @@ -12,7 +12,7 @@ OUT="$ROOT/out" INSTALLER_SRC="$SRC/osx/Installer.Mac" # Product information -IDENTIFIER="com.microsoft.GitCredentialManager" +IDENTIFIER="com.microsoft.gitcredentialmanager" INSTALL_LOCATION="/usr/local/share/gcm-core" # Parse script arguments @@ -50,13 +50,13 @@ if [ -z "$PKGOUT" ]; then die "--output was not set" fi -# Cleanup any old package file +# Cleanup any old component if [ -e "$PKGOUT" ]; then - echo "Deleteing old package '$PKGOUT'..." + echo "Deleteing old component '$PKGOUT'..." rm "$PKGOUT" fi -# Ensure the parent directory for the package exists +# Ensure the parent directory for the component exists mkdir -p "$(dirname "$PKGOUT")" # Set full read, write, execute permissions for owner and just read and execute permissions for group and other @@ -67,8 +67,8 @@ echo "Setting file permissions..." echo "Removing extended attributes..." /usr/bin/xattr -rc "$PAYLOAD" || exit 1 -# Build installer package -echo "Building installer package..." +# Build component packages +echo "Building core component package..." /usr/bin/pkgbuild \ --root "$PAYLOAD/" \ --install-location "$INSTALL_LOCATION" \ @@ -77,4 +77,4 @@ echo "Building installer package..." --version "$VERSION" \ "$PKGOUT" || exit 1 -echo "Pack complete." +echo "Component pack complete." diff --git a/src/osx/Installer.Mac/resources/background.png b/src/osx/Installer.Mac/resources/background.png new file mode 100644 index 000000000..fb9c1b154 Binary files /dev/null and b/src/osx/Installer.Mac/resources/background.png differ diff --git a/src/osx/Installer.Mac/resources/en.lproj/LICENSE b/src/osx/Installer.Mac/resources/en.lproj/LICENSE new file mode 120000 index 000000000..2a64f9d0f --- /dev/null +++ b/src/osx/Installer.Mac/resources/en.lproj/LICENSE @@ -0,0 +1 @@ +../../../../../LICENSE \ No newline at end of file diff --git a/src/osx/Installer.Mac/resources/en.lproj/conclusion.html b/src/osx/Installer.Mac/resources/en.lproj/conclusion.html new file mode 100644 index 000000000..53e817c7d --- /dev/null +++ b/src/osx/Installer.Mac/resources/en.lproj/conclusion.html @@ -0,0 +1,41 @@ + + + + + + + +
+

Git Credential Manager Core was installed in /usr/local/share/gcm-core and configured for the current user.

+
+
+

Other users

+

+ GCM Core has already been automatically configured for use by the current user with Git. + If other users wish to use GCM Core, have them run the following command to update their global Git configuration (~/.gitconfig): +

+

$ git-credential-manager-core configure

+

+ To configure GCM Core for all users, run the following command to update the system Git configuration: +

+

$ git-credential-manager-core configure --system

+
+
+

Uninstall

+

If you wish to uninstall GCM Core, run the following script:

+

$ /usr/local/share/gcm-core/uninstall.sh

+
+
+

Resources

+ +
+ + diff --git a/src/osx/Installer.Mac/resources/en.lproj/welcome.html b/src/osx/Installer.Mac/resources/en.lproj/welcome.html new file mode 100644 index 000000000..633453f20 --- /dev/null +++ b/src/osx/Installer.Mac/resources/en.lproj/welcome.html @@ -0,0 +1,34 @@ + + + + + + + +
+

Git Credential Manager Core

+

+ Git Credential Manager Core is a secure, cross-platform Git credential helper with authentication support for GitHub, Azure Repos, and other popular Git hosting services. +

+
+
+

Installation notes

+

+ If you have the old Java-based Git Credential Manager for Mac & Linux installed through Homebrew, it will be unlinked after installation. +

+

+ Git Credential Manager Core will be configured as the Git credential helper for the current user by updating the global Git configuration file (~/.gitconfig). +

+
+
+

Learn more

+ +
+ + diff --git a/src/osx/Installer.Mac/scripts/postinstall b/src/osx/Installer.Mac/scripts/postinstall index cfb337622..9fdc006f1 100755 --- a/src/osx/Installer.Mac/scripts/postinstall +++ b/src/osx/Installer.Mac/scripts/postinstall @@ -1,6 +1,9 @@ #!/bin/bash set -e +PACKAGE=$1 +INSTALL_DESTINATION=$2 + function IsBrewPkgInstalled { # Check if Homebrew is installed @@ -24,9 +27,9 @@ then fi # Create symlink to GCM in /usr/local/bin -/bin/ln -Fs /usr/local/share/gcm-core/git-credential-manager-core /usr/local/bin/git-credential-manager-core +/bin/ln -Fs "$INSTALL_DESTINATION/git-credential-manager-core" /usr/local/bin/git-credential-manager-core # Configure GCM for the current user -/usr/local/share/gcm-core/git-credential-manager-core configure +"$INSTALL_DESTINATION/git-credential-manager-core" configure exit 0 diff --git a/src/osx/Installer.Mac/uninstall.sh b/src/osx/Installer.Mac/uninstall.sh index 47624086c..2657046d0 100755 --- a/src/osx/Installer.Mac/uninstall.sh +++ b/src/osx/Installer.Mac/uninstall.sh @@ -1,5 +1,8 @@ #!/bin/bash +THISDIR="$( cd "$(dirname "$0")" ; pwd -P )" +GCMBIN="$THISDIR/git-credential-manager-core" + # Ensure we're running as root if [ $(id -u) != "0" ] then @@ -9,7 +12,7 @@ fi # Unconfigure echo "Unconfiguring credential helper..." -/usr/local/share/gcm-core/git-credential-manager-core unconfigure +"$GCMBIN" unconfigure # Remove symlink if [ -L /usr/local/bin/git-credential-manager-core ] @@ -21,13 +24,14 @@ else fi # Forget package installation/delete receipt -sudo pkgutil --forget com.microsoft.GitCredentialManager +echo "Removing installation receipt..." +pkgutil --forget com.microsoft.gitcredentialmanager # Remove application files -if [ -d /usr/local/share/gcm-core/ ] +if [ -d "$THISDIR" ] then echo "Deleting application files..." - sudo rm -rf /usr/local/share/gcm-core/ + rm -rf "$THISDIR" else echo "No application files found." fi diff --git a/src/shared/GitHub/GitHubAuthentication.cs b/src/shared/GitHub/GitHubAuthentication.cs index 284adc671..fb45b1ec1 100644 --- a/src/shared/GitHub/GitHubAuthentication.cs +++ b/src/shared/GitHub/GitHubAuthentication.cs @@ -72,7 +72,7 @@ public async Task GetAuthenticationAsync(Uri targetU if ((modes & AuthenticationModes.Basic) != 0) promptArgs.Append(" --basic"); if ((modes & AuthenticationModes.OAuth) != 0) promptArgs.Append(" --oauth"); if (!GitHubHostProvider.IsGitHubDotCom(targetUri)) promptArgs.AppendFormat(" --enterprise-url {0}", targetUri); - if (!string.IsNullOrWhiteSpace(userName)) promptArgs.AppendFormat("--username {0}", userName); + if (!string.IsNullOrWhiteSpace(userName)) promptArgs.AppendFormat(" --username {0}", userName); IDictionary resultDict = await InvokeHelperAsync(helperPath, promptArgs.ToString(), null); diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index fef1ed6b4..a624a9708 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Git.CredentialManager; using Microsoft.Git.CredentialManager.Authentication; +using Microsoft.Git.CredentialManager.Tests; using Microsoft.Git.CredentialManager.Tests.Objects; using Moq; using Xunit; @@ -14,6 +15,8 @@ namespace Microsoft.AzureRepos.Tests { public class AzureReposHostProviderTests { + private static readonly string HelperKey = + $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; private static readonly string AzDevUseHttpPathKey = $"{Constants.GitConfiguration.Credential.SectionName}.https://dev.azure.com.{Constants.GitConfiguration.Credential.UseHttpPath}"; @@ -179,18 +182,15 @@ public async Task AzureReposProvider_GetCredentialAsync_ReturnsCredential() [Fact] public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathSetTrue_DoesNothing() { - var provider = new AzureReposHostProvider(new TestCommandContext()); + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); - var environment = new TestEnvironment(); - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; + context.Git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; - await provider.ConfigureAsync( - environment, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await provider.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); Assert.Single(actualValues); Assert.Equal("true", actualValues[0]); } @@ -198,18 +198,15 @@ await provider.ConfigureAsync( [Fact] public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathSetFalse_SetsUseHttpPathTrue() { - var provider = new AzureReposHostProvider(new TestCommandContext()); + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); - var environment = new TestEnvironment(); - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"false"}; + context.Git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"false"}; - await provider.ConfigureAsync( - environment, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await provider.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); Assert.Single(actualValues); Assert.Equal("true", actualValues[0]); } @@ -217,36 +214,71 @@ await provider.ConfigureAsync( [Fact] public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathUnset_SetsUseHttpPathTrue() { - var provider = new AzureReposHostProvider(new TestCommandContext()); - - var environment = new TestEnvironment(); - var git = new TestGit(); + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); - await provider.ConfigureAsync( - environment, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await provider.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); Assert.Single(actualValues); Assert.Equal("true", actualValues[0]); } - [Fact] public async Task AzureReposHostProvider_UnconfigureAsync_UseHttpPathSet_RemovesEntry() { - var provider = new AzureReposHostProvider(new TestCommandContext()); + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); + + context.Git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; + + await provider.UnconfigureAsync(ConfigurationTarget.User); + + Assert.Empty(context.Git.GlobalConfiguration.Dictionary); + } + + [PlatformFact(Platforms.Windows)] + public async Task AzureReposHostProvider_UnconfigureAsync_System_Windows_UseHttpPathSetAndManagerCoreHelper_DoesNotRemoveEntry() + { + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); + + context.Git.SystemConfiguration.Dictionary[HelperKey] = new List {"manager-core"}; + context.Git.SystemConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; + + await provider.UnconfigureAsync(ConfigurationTarget.System); + + Assert.True(context.Git.SystemConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(actualValues); + Assert.Equal("true", actualValues[0]); + } + + [PlatformFact(Platforms.Windows)] + public async Task AzureReposHostProvider_UnconfigureAsync_System_Windows_UseHttpPathSetNoManagerCoreHelper_RemovesEntry() + { + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); + + context.Git.SystemConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; + + await provider.UnconfigureAsync(ConfigurationTarget.System); + + Assert.Empty(context.Git.SystemConfiguration.Dictionary); + } + + [PlatformFact(Platforms.Windows)] + public async Task AzureReposHostProvider_UnconfigureAsync_User_Windows_UseHttpPathSetAndManagerCoreHelper_RemovesEntry() + { + var context = new TestCommandContext(); + var provider = new AzureReposHostProvider(context); - var environment = new TestEnvironment(); - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; + context.Git.GlobalConfiguration.Dictionary[HelperKey] = new List {"manager-core"}; + context.Git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; - await provider.UnconfigureAsync( - environment, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await provider.UnconfigureAsync(ConfigurationTarget.User); - Assert.Empty(git.GlobalConfiguration.Dictionary); + Assert.False(context.Git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out _)); } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index bfc2ab1c3..7ee5ed020 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Git.CredentialManager; using Microsoft.Git.CredentialManager.Authentication; @@ -250,37 +251,49 @@ private static string GetAccountNameForCredentialQuery(InputArguments input) string IConfigurableComponent.Name => "Azure Repos provider"; - public Task ConfigureAsync( - IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGit git, GitConfigurationLevel configurationLevel) + public Task ConfigureAsync(ConfigurationTarget target) { string useHttpPathKey = $"{KnownGitCfg.Credential.SectionName}.https://dev.azure.com.{KnownGitCfg.Credential.UseHttpPath}"; - IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); + GitConfigurationLevel configurationLevel = target == ConfigurationTarget.System + ? GitConfigurationLevel.System + : GitConfigurationLevel.Global; - if (targetConfig.TryGetValue(useHttpPathKey, out string currentValue) && currentValue.IsTruthy()) + IGitConfiguration targetConfig = _context.Git.GetConfiguration(configurationLevel); + + if (targetConfig.TryGet(useHttpPathKey, out string currentValue) && currentValue.IsTruthy()) { _context.Trace.WriteLine("Git configuration 'credential.useHttpPath' is already set to 'true' for https://dev.azure.com."); } else { _context.Trace.WriteLine("Setting Git configuration 'credential.useHttpPath' to 'true' for https://dev.azure.com..."); - targetConfig.SetValue(useHttpPathKey, "true"); + targetConfig.Set(useHttpPathKey, "true"); } return Task.CompletedTask; } - public Task UnconfigureAsync( - IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGit git, GitConfigurationLevel configurationLevel) + public Task UnconfigureAsync(ConfigurationTarget target) { + string helperKey = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; string useHttpPathKey = $"{KnownGitCfg.Credential.SectionName}.https://dev.azure.com.{KnownGitCfg.Credential.UseHttpPath}"; _context.Trace.WriteLine("Clearing Git configuration 'credential.useHttpPath' for https://dev.azure.com..."); - IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); - targetConfig.Unset(useHttpPathKey); + GitConfigurationLevel configurationLevel = target == ConfigurationTarget.System + ? GitConfigurationLevel.System + : GitConfigurationLevel.Global; + + IGitConfiguration targetConfig = _context.Git.GetConfiguration(configurationLevel); + + // On Windows, if there is a "manager-core" entry remaining in the system config then we must not clear + // the useHttpPath option otherwise this would break the bundled version of GCM Core in Git for Windows. + if (!PlatformUtils.IsWindows() || target != ConfigurationTarget.System || + targetConfig.GetAll(helperKey).All(x => !string.Equals(x, "manager-core"))) + { + targetConfig.Unset(useHttpPathKey); + } return Task.CompletedTask; } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs index 5a0b16350..bdb78ad0e 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs @@ -1,217 +1,305 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Git.CredentialManager.Tests.Objects; -using Moq; using Xunit; namespace Microsoft.Git.CredentialManager.Tests { public class ApplicationTests { - #region Common configuration tests + [Fact] + public async Task Application_ConfigureAsync_NoHelpers_AddsEmptyAndGcm() + { + const string emptyHelper = ""; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; + + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); + await application.ConfigureAsync(ConfigurationTarget.User); + + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(2, actualValues.Count); + Assert.Equal(emptyHelper, actualValues[0]); + Assert.Equal(executablePath, actualValues[1]); + } [Fact] - public async Task Application_ConfigureAsync_HelperSet_DoesNothing() + public async Task Application_ConfigureAsync_Gcm_AddsEmptyBeforeGcm() { const string emptyHelper = ""; - const string gcmConfigName = "manager-core"; const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); + + context.Git.GlobalConfiguration.Dictionary[key] = new List {executablePath}; - var environment = new Mock(); + await application.ConfigureAsync(ConfigurationTarget.User); + + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(2, actualValues.Count); + Assert.Equal(emptyHelper, actualValues[0]); + Assert.Equal(executablePath, actualValues[1]); + } + + [Fact] + public async Task Application_ConfigureAsync_EmptyAndGcm_DoesNothing() + { + const string emptyHelper = ""; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[key] = new List + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); + + context.Git.GlobalConfiguration.Dictionary[key] = new List { - emptyHelper, gcmConfigName + emptyHelper, executablePath }; - await application.ConfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); Assert.Equal(2, actualValues.Count); Assert.Equal(emptyHelper, actualValues[0]); - Assert.Equal(gcmConfigName, actualValues[1]); + Assert.Equal(executablePath, actualValues[1]); } [Fact] - public async Task Application_ConfigureAsync_HelperSetWithOthersPreceding_DoesNothing() + public async Task Application_ConfigureAsync_EmptyAndGcmWithOthersBefore_DoesNothing() { const string emptyHelper = ""; - const string gcmConfigName = "manager-core"; + const string beforeHelper = "foo"; const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); - - var environment = new Mock(); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[key] = new List + context.Git.GlobalConfiguration.Dictionary[key] = new List { - "foo", "bar", emptyHelper, gcmConfigName + beforeHelper, emptyHelper, executablePath }; - await application.ConfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); - Assert.Equal(4, actualValues.Count); - Assert.Equal("foo", actualValues[0]); - Assert.Equal("bar", actualValues[1]); - Assert.Equal(emptyHelper, actualValues[2]); - Assert.Equal(gcmConfigName, actualValues[3]); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(3, actualValues.Count); + Assert.Equal(beforeHelper, actualValues[0]); + Assert.Equal(emptyHelper, actualValues[1]); + Assert.Equal(executablePath, actualValues[2]); } [Fact] - public async Task Application_ConfigureAsync_HelperSetWithOthersFollowing_ClearsEntriesSetsHelper() + public async Task Application_ConfigureAsync_EmptyAndGcmWithOthersAfter_DoesNothing() { const string emptyHelper = ""; - const string gcmConfigName = "manager-core"; + const string afterHelper = "foo"; const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var environment = new Mock(); - - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[key] = new List + context.Git.GlobalConfiguration.Dictionary[key] = new List { - "bar", emptyHelper, executablePath, "foo" + emptyHelper, executablePath, afterHelper }; - await application.ConfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); - Assert.Equal(2, actualValues.Count); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(3, actualValues.Count); Assert.Equal(emptyHelper, actualValues[0]); - Assert.Equal(gcmConfigName, actualValues[1]); + Assert.Equal(executablePath, actualValues[1]); + Assert.Equal(afterHelper, actualValues[2]); } [Fact] - public async Task Application_ConfigureAsync_HelperNotSet_SetsHelper() + public async Task Application_ConfigureAsync_EmptyAndGcmWithOthersBeforeAndAfter_DoesNothing() { const string emptyHelper = ""; - const string gcmConfigName = "manager-core"; + const string beforeHelper = "foo"; + const string afterHelper = "bar"; const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var environment = new Mock(); - - var git = new TestGit(); + context.Git.GlobalConfiguration.Dictionary[key] = new List + { + beforeHelper, emptyHelper, executablePath, afterHelper + }; - await application.ConfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.ConfigureAsync(ConfigurationTarget.User); - Assert.Single(git.GlobalConfiguration.Dictionary); - Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); - Assert.Equal(2, actualValues.Count); - Assert.Equal(emptyHelper, actualValues[0]); - Assert.Equal(gcmConfigName, actualValues[1]); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(4, actualValues.Count); + Assert.Equal(beforeHelper, actualValues[0]); + Assert.Equal(emptyHelper, actualValues[1]); + Assert.Equal(executablePath, actualValues[2]); + Assert.Equal(afterHelper, actualValues[3]); } [Fact] - public async Task Application_UnconfigureAsync_HelperSet_RemovesEntries() + public async Task Application_ConfigureAsync_EmptyAndGcmWithEmptyAfter_RemovesExistingGcmAndAddsEmptyAndGcm() { const string emptyHelper = ""; - const string gcmConfigName = "manager-core"; + const string afterHelper = "foo"; const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var environment = new Mock(); - var git = new TestGit(); - git.GlobalConfiguration.Dictionary[key] = new List {emptyHelper, gcmConfigName}; + context.Git.GlobalConfiguration.Dictionary[key] = new List + { + emptyHelper, executablePath, emptyHelper, afterHelper + }; - await application.UnconfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.ConfigureAsync(ConfigurationTarget.User); - Assert.Empty(git.GlobalConfiguration.Dictionary); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(5, actualValues.Count); + Assert.Equal(emptyHelper, actualValues[0]); + Assert.Equal(emptyHelper, actualValues[1]); + Assert.Equal(afterHelper, actualValues[2]); + Assert.Equal(emptyHelper, actualValues[3]); + Assert.Equal(executablePath, actualValues[4]); } - #endregion + [Fact] + public async Task Application_UnconfigureAsync_NoHelpers_DoesNothing() + { + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - #region Windows-specific configuration tests + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); + await application.UnconfigureAsync(ConfigurationTarget.User); - [PlatformFact(Platforms.Windows)] - public async Task Application_ConfigureAsync_User_PathSet_DoesNothing() - { - const string directoryPath = @"X:\Install Location"; - const string executablePath = @"X:\Install Location\git-credential-manager-core.exe"; + Assert.Empty(context.Git.GlobalConfiguration.Dictionary); + } - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + [Fact] + public async Task Application_UnconfigureAsync_Gcm_RemovesGcm() + { + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var environment = new Mock(); - environment.Setup(x => x.IsDirectoryOnPath(directoryPath)).Returns(true); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var git = new TestGit(); + context.Git.GlobalConfiguration.Dictionary[key] = new List {executablePath}; - await application.ConfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.UnconfigureAsync(ConfigurationTarget.User); - environment.Verify(x => x.AddDirectoryToPath(It.IsAny(), It.IsAny()), Times.Never); + Assert.Empty(context.Git.GlobalConfiguration.Dictionary); } - [PlatformFact(Platforms.Windows)] - public async Task Application_ConfigureAsync_User_PathNotSet_SetsUserPath() + [Fact] + public async Task Application_UnconfigureAsync_EmptyAndGcm_RemovesEmptyAndGcm() { - const string directoryPath = @"X:\Install Location"; - const string executablePath = @"X:\Install Location\git-credential-manager-core.exe"; - - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + const string emptyHelper = ""; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var environment = new Mock(); - environment.Setup(x => x.IsDirectoryOnPath(directoryPath)).Returns(false); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var git = new TestGit(); + context.Git.GlobalConfiguration.Dictionary[key] = new List {emptyHelper, executablePath}; - await application.ConfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + await application.UnconfigureAsync(ConfigurationTarget.User); - environment.Verify(x => x.AddDirectoryToPath(directoryPath, EnvironmentVariableTarget.User), Times.Once); + Assert.Empty(context.Git.GlobalConfiguration.Dictionary); } - [PlatformFact(Platforms.Windows)] - public async Task Application_UnconfigureAsync_User_PathSet_RemovesFromUserPath() + [Fact] + public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersBefore_RemovesEmptyAndGcm() { - const string directoryPath = @"X:\Install Location"; - const string executablePath = @"X:\Install Location\git-credential-manager-core.exe"; + const string emptyHelper = ""; + const string beforeHelper = "foo"; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); - var environment = new Mock(); - environment.Setup(x => x.IsDirectoryOnPath(directoryPath)).Returns(true); + context.Git.GlobalConfiguration.Dictionary[key] = new List + { + beforeHelper, emptyHelper, executablePath + }; - var git = new TestGit(); + await application.UnconfigureAsync(ConfigurationTarget.User); - await application.UnconfigureAsync( - environment.Object, EnvironmentVariableTarget.User, - git, GitConfigurationLevel.Global); + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(1, actualValues.Count); + Assert.Equal(beforeHelper, actualValues[0]); + } - environment.Verify(x => x.RemoveDirectoryFromPath(directoryPath, EnvironmentVariableTarget.User), Times.Once); + [Fact] + public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersAfterBefore_RemovesGcmOnly() + { + const string emptyHelper = ""; + const string afterHelper = "bar"; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; + + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); + + context.Git.GlobalConfiguration.Dictionary[key] = new List + { + emptyHelper, executablePath, afterHelper + }; + + await application.UnconfigureAsync(ConfigurationTarget.User); + + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(2, actualValues.Count); + Assert.Equal(emptyHelper, actualValues[0]); + Assert.Equal(afterHelper, actualValues[1]); } - #endregion + [Fact] + public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersBeforeAndAfter_RemovesGcmOnly() + { + const string emptyHelper = ""; + const string beforeHelper = "foo"; + const string afterHelper = "bar"; + const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; + string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; + + var context = new TestCommandContext(); + IConfigurableComponent application = new Application(context, executablePath); + + context.Git.GlobalConfiguration.Dictionary[key] = new List + { + beforeHelper, emptyHelper, executablePath, afterHelper + }; + + await application.UnconfigureAsync(ConfigurationTarget.User); + + Assert.Single(context.Git.GlobalConfiguration.Dictionary); + Assert.True(context.Git.GlobalConfiguration.Dictionary.TryGetValue(key, out var actualValues)); + Assert.Equal(3, actualValues.Count); + Assert.Equal(beforeHelper, actualValues[0]); + Assert.Equal(emptyHelper, actualValues[1]); + Assert.Equal(afterHelper, actualValues[2]); + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs index ea84a6b08..e2644ad3f 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System; using System.Threading.Tasks; using Microsoft.Git.CredentialManager.Tests.Objects; using Moq; @@ -26,17 +25,11 @@ public async Task ConfigurationService_ConfigureAsync_System_ComponentsAreConfig await service.ConfigureAsync(ConfigurationTarget.System); - component1.Verify(x => x.ConfigureAsync( - context.Environment, EnvironmentVariableTarget.Machine, - context.Git, GitConfigurationLevel.System), + component1.Verify(x => x.ConfigureAsync(ConfigurationTarget.System), Times.Once); - component2.Verify(x => x.ConfigureAsync( - context.Environment, EnvironmentVariableTarget.Machine, - context.Git, GitConfigurationLevel.System), + component2.Verify(x => x.ConfigureAsync(ConfigurationTarget.System), Times.Once); - component3.Verify(x => x.ConfigureAsync( - context.Environment, EnvironmentVariableTarget.Machine, - context.Git, GitConfigurationLevel.System), + component3.Verify(x => x.ConfigureAsync(ConfigurationTarget.System), Times.Once); } @@ -56,17 +49,11 @@ public async Task ConfigurationService_ConfigureAsync_User_ComponentsAreConfigur await service.ConfigureAsync(ConfigurationTarget.User); - component1.Verify(x => x.ConfigureAsync( - context.Environment, EnvironmentVariableTarget.User, - context.Git, GitConfigurationLevel.Global), + component1.Verify(x => x.ConfigureAsync(ConfigurationTarget.User), Times.Once); - component2.Verify(x => x.ConfigureAsync( - context.Environment, EnvironmentVariableTarget.User, - context.Git, GitConfigurationLevel.Global), + component2.Verify(x => x.ConfigureAsync(ConfigurationTarget.User), Times.Once); - component3.Verify(x => x.ConfigureAsync( - context.Environment, EnvironmentVariableTarget.User, - context.Git, GitConfigurationLevel.Global), + component3.Verify(x => x.ConfigureAsync(ConfigurationTarget.User), Times.Once); } @@ -86,17 +73,11 @@ public async Task ConfigurationService_UnconfigureAsync_System_ComponentsAreUnco await service.UnconfigureAsync(ConfigurationTarget.System); - component1.Verify(x => x.UnconfigureAsync( - context.Environment, EnvironmentVariableTarget.Machine, - context.Git, GitConfigurationLevel.System), + component1.Verify(x => x.UnconfigureAsync(ConfigurationTarget.System), Times.Once); - component2.Verify(x => x.UnconfigureAsync( - context.Environment, EnvironmentVariableTarget.Machine, - context.Git, GitConfigurationLevel.System), + component2.Verify(x => x.UnconfigureAsync(ConfigurationTarget.System), Times.Once); - component3.Verify(x => x.UnconfigureAsync( - context.Environment, EnvironmentVariableTarget.Machine, - context.Git, GitConfigurationLevel.System), + component3.Verify(x => x.UnconfigureAsync(ConfigurationTarget.System), Times.Once); } @@ -116,17 +97,11 @@ public async Task ConfigurationService_UnconfigureAsync_User_ComponentsAreUnconf await service.UnconfigureAsync(ConfigurationTarget.User); - component1.Verify(x => x.UnconfigureAsync( - context.Environment, EnvironmentVariableTarget.User, - context.Git, GitConfigurationLevel.Global), + component1.Verify(x => x.UnconfigureAsync(ConfigurationTarget.User), Times.Once); - component2.Verify(x => x.UnconfigureAsync( - context.Environment, EnvironmentVariableTarget.User, - context.Git, GitConfigurationLevel.Global), + component2.Verify(x => x.UnconfigureAsync(ConfigurationTarget.User), Times.Once); - component3.Verify(x => x.UnconfigureAsync( - context.Environment, EnvironmentVariableTarget.User, - context.Git, GitConfigurationLevel.Global), + component3.Verify(x => x.UnconfigureAsync(ConfigurationTarget.User), Times.Once); } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs index 5c332303c..89aae5d48 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs @@ -11,6 +11,39 @@ namespace Microsoft.Git.CredentialManager.Tests { public class GitConfigurationTests { + [Theory] + [InlineData(null, "\"\"")] + [InlineData("", "\"\"")] + [InlineData("hello", "hello")] + [InlineData("hello world", "\"hello world\"")] + [InlineData("C:\\app.exe", "C:\\app.exe")] + [InlineData("C:\\path with space\\app.exe", "\"C:\\path with space\\app.exe\"")] + [InlineData("''", "\"''\"")] + [InlineData("'hello'", "\"'hello'\"")] + [InlineData("'hello world'", "\"'hello world'\"")] + [InlineData("'C:\\app.exe'", "\"'C:\\app.exe'\"")] + [InlineData("'C:\\path with space\\app.exe'", "\"'C:\\path with space\\app.exe'\"")] + [InlineData("\"\"", "\"\\\"\\\"\"")] + [InlineData("\"hello\"", "\"\\\"hello\\\"\"")] + [InlineData("\"hello world\"", "\"\\\"hello world\\\"\"")] + [InlineData("\"C:\\app.exe\"", "\"\\\"C:\\app.exe\\\"\"")] + [InlineData("\"C:\\path with space\\app.exe\"", "\"\\\"C:\\path with space\\app.exe\\\"\"")] + [InlineData("\\", "\\")] + [InlineData("\\\\", "\\\\")] + [InlineData("\\\\\\", "\\\\\\")] + [InlineData("\"", "\"\\\"\"")] + [InlineData("\\\"", "\"\\\\\\\"\"")] + [InlineData("\\\\\"", "\"\\\\\\\\\\\"\"")] + [InlineData("\"\\", "\"\\\"\\\\\"")] + [InlineData("\"\\\\", "\"\\\"\\\\\\\\\"")] + [InlineData("ab\\", "ab\\")] + [InlineData("a b\\", "\"a b\\\\\"")] + public void GitConfiguration_QuoteCmdArg(string input, string expected) + { + string actual = GitProcessConfiguration.QuoteCmdArg(input); + Assert.Equal(expected, actual); + } + [Fact] public void GitProcess_GetConfiguration_ReturnsConfiguration() { @@ -97,7 +130,7 @@ bool cb(string name, string value) } [Fact] - public void GitConfiguration_TryGetValue_Name_Exists_ReturnsTrueOutString() + public void GitConfiguration_TryGet_Name_Exists_ReturnsTrueOutString() { string repoPath = CreateRepository(out string workDirPath); Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); @@ -107,14 +140,14 @@ public void GitConfiguration_TryGetValue_Name_Exists_ReturnsTrueOutString() var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - bool result = config.TryGetValue("user.name", out string value); + bool result = config.TryGet("user.name", out string value); Assert.True(result); Assert.NotNull(value); Assert.Equal("john.doe", value); } [Fact] - public void GitConfiguration_TryGetValue_Name_DoesNotExists_ReturnsFalse() + public void GitConfiguration_TryGet_Name_DoesNotExists_ReturnsFalse() { string repoPath = CreateRepository(); @@ -124,13 +157,13 @@ public void GitConfiguration_TryGetValue_Name_DoesNotExists_ReturnsFalse() IGitConfiguration config = git.GetConfiguration(); string randomName = $"{Guid.NewGuid():N}.{Guid.NewGuid():N}"; - bool result = config.TryGetValue(randomName, out string value); + bool result = config.TryGet(randomName, out string value); Assert.False(result); Assert.Null(value); } [Fact] - public void GitConfiguration_TryGetValue_SectionProperty_Exists_ReturnsTrueOutString() + public void GitConfiguration_TryGet_SectionProperty_Exists_ReturnsTrueOutString() { string repoPath = CreateRepository(out string workDirPath); Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); @@ -140,14 +173,14 @@ public void GitConfiguration_TryGetValue_SectionProperty_Exists_ReturnsTrueOutSt var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - bool result = config.TryGetValue("user", "name", out string value); + bool result = config.TryGet("user", "name", out string value); Assert.True(result); Assert.NotNull(value); Assert.Equal("john.doe", value); } [Fact] - public void GitConfiguration_TryGetValue_SectionProperty_DoesNotExists_ReturnsFalse() + public void GitConfiguration_TryGet_SectionProperty_DoesNotExists_ReturnsFalse() { string repoPath = CreateRepository(); @@ -158,13 +191,13 @@ public void GitConfiguration_TryGetValue_SectionProperty_DoesNotExists_ReturnsFa string randomSection = Guid.NewGuid().ToString("N"); string randomProperty = Guid.NewGuid().ToString("N"); - bool result = config.TryGetValue(randomSection, randomProperty, out string value); + bool result = config.TryGet(randomSection, randomProperty, out string value); Assert.False(result); Assert.Null(value); } [Fact] - public void GitConfiguration_TryGetValue_SectionScopeProperty_Exists_ReturnsTrueOutString() + public void GitConfiguration_TryGet_SectionScopeProperty_Exists_ReturnsTrueOutString() { string repoPath = CreateRepository(out string workDirPath); Git(repoPath, workDirPath, "config --local user.example.com.name john.doe").AssertSuccess(); @@ -174,14 +207,14 @@ public void GitConfiguration_TryGetValue_SectionScopeProperty_Exists_ReturnsTrue var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - bool result = config.TryGetValue("user", "example.com", "name", out string value); + bool result = config.TryGet("user", "example.com", "name", out string value); Assert.True(result); Assert.NotNull(value); Assert.Equal("john.doe", value); } [Fact] - public void GitConfiguration_TryGetValue_SectionScopeProperty_NullScope_ReturnsTrueOutUnscopedString() + public void GitConfiguration_TryGet_SectionScopeProperty_NullScope_ReturnsTrueOutUnscopedString() { string repoPath = CreateRepository(out string workDirPath); Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); @@ -191,14 +224,14 @@ public void GitConfiguration_TryGetValue_SectionScopeProperty_NullScope_ReturnsT var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - bool result = config.TryGetValue("user", null, "name", out string value); + bool result = config.TryGet("user", null, "name", out string value); Assert.True(result); Assert.NotNull(value); Assert.Equal("john.doe", value); } [Fact] - public void GitConfiguration_TryGetValue_SectionScopeProperty_DoesNotExists_ReturnsFalse() + public void GitConfiguration_TryGet_SectionScopeProperty_DoesNotExists_ReturnsFalse() { string repoPath = CreateRepository(); @@ -210,7 +243,7 @@ public void GitConfiguration_TryGetValue_SectionScopeProperty_DoesNotExists_Retu string randomSection = Guid.NewGuid().ToString("N"); string randomScope = Guid.NewGuid().ToString("N"); string randomProperty = Guid.NewGuid().ToString("N"); - bool result = config.TryGetValue(randomSection, randomScope, randomProperty, out string value); + bool result = config.TryGet(randomSection, randomScope, randomProperty, out string value); Assert.False(result); Assert.Null(value); } @@ -226,7 +259,7 @@ public void GitConfiguration_GetString_Name_Exists_ReturnsString() var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - string value = config.GetValue("user.name"); + string value = config.Get("user.name"); Assert.NotNull(value); Assert.Equal("john.doe", value); } @@ -242,7 +275,7 @@ public void GitConfiguration_GetString_Name_DoesNotExists_ThrowsException() IGitConfiguration config = git.GetConfiguration(); string randomName = $"{Guid.NewGuid():N}.{Guid.NewGuid():N}"; - Assert.Throws(() => config.GetValue(randomName)); + Assert.Throws(() => config.Get(randomName)); } [Fact] @@ -256,7 +289,7 @@ public void GitConfiguration_GetString_SectionProperty_Exists_ReturnsString() var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - string value = config.GetValue("user", "name"); + string value = config.Get("user", "name"); Assert.NotNull(value); Assert.Equal("john.doe", value); } @@ -273,7 +306,7 @@ public void GitConfiguration_GetString_SectionProperty_DoesNotExists_ThrowsExcep string randomSection = Guid.NewGuid().ToString("N"); string randomProperty = Guid.NewGuid().ToString("N"); - Assert.Throws(() => config.GetValue(randomSection, randomProperty)); + Assert.Throws(() => config.Get(randomSection, randomProperty)); } [Fact] @@ -287,7 +320,7 @@ public void GitConfiguration_GetString_SectionScopeProperty_Exists_ReturnsString var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - string value = config.GetValue("user", "example.com", "name"); + string value = config.Get("user", "example.com", "name"); Assert.NotNull(value); Assert.Equal("john.doe", value); } @@ -303,7 +336,7 @@ public void GitConfiguration_GetString_SectionScopeProperty_NullScope_ReturnsUns var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - string value = config.GetValue("user", null, "name"); + string value = config.Get("user", null, "name"); Assert.NotNull(value); Assert.Equal("john.doe", value); } @@ -321,11 +354,11 @@ public void GitConfiguration_GetString_SectionScopeProperty_DoesNotExists_Throws string randomSection = Guid.NewGuid().ToString("N"); string randomScope = Guid.NewGuid().ToString("N"); string randomProperty = Guid.NewGuid().ToString("N"); - Assert.Throws(() => config.GetValue(randomSection, randomScope, randomProperty)); + Assert.Throws(() => config.Get(randomSection, randomScope, randomProperty)); } [Fact] - public void GitConfiguration_SetValue_Local_SetsLocalConfig() + public void GitConfiguration_Set_Local_SetsLocalConfig() { string repoPath = CreateRepository(out string workDirPath); @@ -334,7 +367,7 @@ public void GitConfiguration_SetValue_Local_SetsLocalConfig() var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(GitConfigurationLevel.Local); - config.SetValue("core.foobar", "foo123"); + config.Set("core.foobar", "foo123"); GitResult localResult = Git(repoPath, workDirPath, "config --local core.foobar"); @@ -342,7 +375,7 @@ public void GitConfiguration_SetValue_Local_SetsLocalConfig() } [Fact] - public void GitConfiguration_SetValue_All_ThrowsException() + public void GitConfiguration_Set_All_ThrowsException() { string repoPath = CreateRepository(out _); @@ -351,7 +384,7 @@ public void GitConfiguration_SetValue_All_ThrowsException() var git = new GitProcess(trace, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(GitConfigurationLevel.All); - Assert.Throws(() => config.SetValue("core.foobar", "test123")); + Assert.Throws(() => config.Set("core.foobar", "test123")); } [Fact] diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs index dd8cef277..0bc16592d 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs @@ -319,6 +319,115 @@ public void Settings_IsSecretTracingEnabled_EnvarFalsey_ReturnsFalse() Assert.False(settings.IsSecretTracingEnabled); } + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_EnvarUnset_ReturnsTrue() + { + var envars = new TestEnvironment(); + var git = new TestGit(); + + var settings = new Settings(envars, git); + + Assert.True(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_EnvarTruthy_ReturnsTrue() + { + var envars = new TestEnvironment + { + Variables = {[Constants.EnvironmentVariables.GcmAllowWia] = "1"} + }; + var git = new TestGit(); + + var settings = new Settings(envars, git); + + Assert.True(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_EnvarFalsey_ReturnsFalse() + { + var envars = new TestEnvironment + { + Variables = {[Constants.EnvironmentVariables.GcmAllowWia] = "0"}, + }; + var git = new TestGit(); + + var settings = new Settings(envars, git); + + Assert.False(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_EnvarNonBooleanyValue_ReturnsTrue() + { + var envars = new TestEnvironment + { + Variables = {[Constants.EnvironmentVariables.GcmAllowWia] = Guid.NewGuid().ToString("N")}, + }; + var git = new TestGit(); + + var settings = new Settings(envars, git); + + Assert.True(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_ConfigUnset_ReturnsTrue() + { + var envars = new TestEnvironment(); + var git = new TestGit(); + + var settings = new Settings(envars, git); + + Assert.True(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_ConfigTruthy_ReturnsTrue() + { + const string section = Constants.GitConfiguration.Credential.SectionName; + const string property = Constants.GitConfiguration.Credential.AllowWia; + + var envars = new TestEnvironment(); + var git = new TestGit(); + git.GlobalConfiguration[$"{section}.{property}"] = "1"; + + var settings = new Settings(envars, git); + + Assert.True(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_ConfigFalsey_ReturnsFalse() + { + const string section = Constants.GitConfiguration.Credential.SectionName; + const string property = Constants.GitConfiguration.Credential.AllowWia; + + var envars = new TestEnvironment(); + var git = new TestGit(); + git.GlobalConfiguration[$"{section}.{property}"] = "0"; + + var settings = new Settings(envars, git); + + Assert.False(settings.IsWindowsIntegratedAuthenticationEnabled); + } + + [Fact] + public void Settings_IsWindowsIntegratedAuthenticationEnabled_ConfigNonBooleanyValue_ReturnsTrue() + { + const string section = Constants.GitConfiguration.Credential.SectionName; + const string property = Constants.GitConfiguration.Credential.AllowWia; + + var envars = new TestEnvironment(); + var git = new TestGit(); + git.GlobalConfiguration[$"{section}.{property}"] = Guid.NewGuid().ToString(); + + var settings = new Settings(envars, git); + + Assert.True(settings.IsWindowsIntegratedAuthenticationEnabled); + } + [Fact] public void Settings_ProxyConfiguration_Unset_ReturnsNull() { diff --git a/src/shared/Microsoft.Git.CredentialManager/Application.cs b/src/shared/Microsoft.Git.CredentialManager/Application.cs index 37c7c4a3f..9724959c1 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Application.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Application.cs @@ -141,90 +141,93 @@ protected bool WriteException(Exception ex) string IConfigurableComponent.Name => "Git Credential Manager"; - Task IConfigurableComponent.ConfigureAsync( - IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGit git, GitConfigurationLevel configurationLevel) + Task IConfigurableComponent.ConfigureAsync(ConfigurationTarget target) { - // NOTE: We currently only update the PATH in Windows installations and leave putting the GCM executable - // on the PATH on other platform to their installers. - if (PlatformUtils.IsWindows()) + string helperKey = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; + string appPath = GetGitConfigAppPath(); + + GitConfigurationLevel configLevel = target == ConfigurationTarget.System + ? GitConfigurationLevel.System + : GitConfigurationLevel.Global; + + Context.Trace.WriteLine($"Configuring for config level '{configLevel}'."); + + IGitConfiguration config = Context.Git.GetConfiguration(configLevel); + + // We are looking for the following to be set in the config: + // + // [credential] + // ... # any number of helper entries (possibly none) + // helper = # an empty value to reset/clear any previous entries (if applicable) + // helper = {appPath} # the expected executable value & directly following the empty value + // ... # any number of helper entries (possibly none, but not the empty value '') + // + string[] currentValues = config.GetAll(helperKey).ToArray(); + + // Try to locate an existing app entry with a blank reset/clear entry immediately preceding, + // and no other blank empty/clear entries following (which effectively disable us). + int appIndex = Array.FindIndex(currentValues, x => Context.FileSystem.IsSamePath(x, appPath)); + int lastEmptyIndex = Array.FindLastIndex(currentValues, string.IsNullOrWhiteSpace); + if (appIndex > 0 && string.IsNullOrWhiteSpace(currentValues[appIndex - 1]) && lastEmptyIndex < appIndex) { - string directoryPath = Path.GetDirectoryName(_appPath); - if (!environment.IsDirectoryOnPath(directoryPath)) - { - Context.Trace.WriteLine("Adding application to PATH..."); - environment.AddDirectoryToPath(directoryPath, environmentTarget); - } - else - { - Context.Trace.WriteLine("Application is already on the PATH."); - } + Context.Trace.WriteLine("Credential helper configuration is already set correctly."); } - - string helperKey = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - string gitConfigAppName = GetGitConfigAppName(); - - IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); - - /* - * We are looking for the following to be considered already set: - * - * [credential] - * ... # any number of helper entries - * helper = # an empty value to reset/clear any previous entries - * helper = {gitConfigAppName} # the expected executable value in the last position & directly following the empty value - * - */ - - string[] currentValues = targetConfig.GetRegex(helperKey, Constants.RegexPatterns.Any).ToArray(); - if (currentValues.Length < 2 || - !string.IsNullOrWhiteSpace(currentValues[currentValues.Length - 2]) || // second to last entry is empty - currentValues[currentValues.Length - 1] != gitConfigAppName) // last entry is the expected executable + else { Context.Trace.WriteLine("Updating Git credential helper configuration..."); - // Clear any existing entries in the configuration. - targetConfig.UnsetAll(helperKey, Constants.RegexPatterns.Any); + // Clear any existing app entries in the configuration + config.UnsetAll(helperKey, Regex.Escape(appPath)); // Add an empty value for `credential.helper`, which has the effect of clearing any helper value // from any lower-level Git configuration, then add a second value which is the actual executable path. - targetConfig.SetValue(helperKey, string.Empty); - targetConfig.ReplaceAll(helperKey, Constants.RegexPatterns.None, gitConfigAppName); + config.Add(helperKey, string.Empty); + config.Add(helperKey, appPath); } - else - { - Context.Trace.WriteLine("Credential helper configuration is already set correctly."); - } - return Task.CompletedTask; } - Task IConfigurableComponent.UnconfigureAsync( - IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGit git, GitConfigurationLevel configurationLevel) + Task IConfigurableComponent.UnconfigureAsync(ConfigurationTarget target) { string helperKey = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - string gitConfigAppName = GetGitConfigAppName(); - - IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); - + string appPath = GetGitConfigAppPath(); + + GitConfigurationLevel configLevel = target == ConfigurationTarget.System + ? GitConfigurationLevel.System + : GitConfigurationLevel.Global; + + Context.Trace.WriteLine($"Unconfiguring for config level '{configLevel}'."); + + IGitConfiguration config = Context.Git.GetConfiguration(configLevel); + + // We are looking for the following to be set in the config: + // + // [credential] + // ... # any number of helper entries (possibly none) + // helper = # an empty value to reset/clear any previous entries (if applicable) + // helper = {appPath} # the expected executable value & directly following the empty value + // ... # any number of helper entries (possibly none) + // + // We should remove the {appPath} entry, and any blank entries immediately preceding IFF there are no more entries following. + // Context.Trace.WriteLine("Removing Git credential helper configuration..."); - // Clear any blank 'reset' entries - targetConfig.UnsetAll(helperKey, Constants.RegexPatterns.Empty); + string[] currentValues = config.GetAll(helperKey).ToArray(); - // Clear GCM executable entries - targetConfig.UnsetAll(helperKey, Regex.Escape(gitConfigAppName)); - - // NOTE: We currently only update the PATH in Windows installations and leave removing the GCM executable - // on the PATH on other platform to their installers. - // Remove GCM executable from the PATH - if (PlatformUtils.IsWindows()) + int appIndex = Array.FindIndex(currentValues, x => Context.FileSystem.IsSamePath(x, appPath)); + if (appIndex > -1) { - Context.Trace.WriteLine("Removing application from the PATH..."); - string directoryPath = Path.GetDirectoryName(_appPath); - environment.RemoveDirectoryFromPath(directoryPath, environmentTarget); + // Check for the presence of a blank entry immediately preceding an app entry in the last position + if (appIndex > 0 && appIndex == currentValues.Length - 1 && + string.IsNullOrWhiteSpace(currentValues[appIndex - 1])) + { + // Clear the blank entry + config.UnsetAll(helperKey, Constants.RegexPatterns.Empty); + } + + // Clear app entry + config.UnsetAll(helperKey, Regex.Escape(appPath)); } return Task.CompletedTask; @@ -243,6 +246,23 @@ private string GetGitConfigAppName() return _appPath; } + private string GetGitConfigAppPath() + { + string path = _appPath; + + // On Windows we must use UNIX style path separators + if (PlatformUtils.IsWindows()) + { + path = path.Replace('\\', '/'); + } + + // We must escape escape characters like ' ', '(', and ')' + return path + .Replace(" ", "\\ ") + .Replace("(", "\\(") + .Replace(")", "\\)");; + } + #endregion } } diff --git a/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs b/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs index 7c9d3cc60..6e084c229 100644 --- a/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs +++ b/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -35,22 +34,14 @@ public interface IConfigurableComponent /// /// Configure the environment and Git to work with this hosting provider. /// - /// Environment variables. - /// Environment variable target to update. - /// Git object. - /// Git configuration level to update. - Task ConfigureAsync(IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGit git, GitConfigurationLevel configurationLevel); + /// Configuration target. + Task ConfigureAsync(ConfigurationTarget target); /// /// Remove changes to the environment and Git configuration previously made with . /// - /// Environment variables. - /// Environment variable target to update. - /// Git object. - /// Git configuration level to update. - Task UnconfigureAsync(IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGit git, GitConfigurationLevel configurationLevel); + /// Configuration target. + Task UnconfigureAsync(ConfigurationTarget target); } public interface IConfigurationService @@ -93,44 +84,23 @@ public void AddComponent(IConfigurableComponent component) _components.Add(component); } - public Task ConfigureAsync(ConfigurationTarget target) => RunAsync(target, true); - - public Task UnconfigureAsync(ConfigurationTarget target) => RunAsync(target, false); - - private async Task RunAsync(ConfigurationTarget target, bool configure) + public async Task ConfigureAsync(ConfigurationTarget target) { - GitConfigurationLevel configLevel; - EnvironmentVariableTarget envTarget; - switch (target) + foreach (IConfigurableComponent component in _components) { - case ConfigurationTarget.User: - configLevel = GitConfigurationLevel.Global; - envTarget = EnvironmentVariableTarget.User; - break; - - case ConfigurationTarget.System: - configLevel = GitConfigurationLevel.System; - envTarget = EnvironmentVariableTarget.Machine; - break; - - default: - throw new ArgumentOutOfRangeException(nameof(target)); + _context.Trace.WriteLine($"Configuring component '{component.Name}'..."); + _context.Streams.Error.WriteLine($"Configuring component '{component.Name}'..."); + await component.ConfigureAsync(target); } + } + public async Task UnconfigureAsync(ConfigurationTarget target) + { foreach (IConfigurableComponent component in _components) { - if (configure) - { - _context.Trace.WriteLine($"Configuring component '{component.Name}'..."); - _context.Streams.Error.WriteLine($"Configuring component '{component.Name}'..."); - await component.ConfigureAsync(_context.Environment, envTarget, _context.Git, configLevel); - } - else - { - _context.Trace.WriteLine($"Unconfiguring component '{component.Name}'..."); - _context.Streams.Error.WriteLine($"Unconfiguring component '{component.Name}'..."); - await component.UnconfigureAsync(_context.Environment, envTarget, _context.Git, configLevel); - } + _context.Trace.WriteLine($"Unconfiguring component '{component.Name}'..."); + _context.Streams.Error.WriteLine($"Unconfiguring component '{component.Name}'..."); + await component.UnconfigureAsync(target); } } diff --git a/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs b/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs index 86f9c52cb..0915b1455 100644 --- a/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs +++ b/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text; namespace Microsoft.Git.CredentialManager { @@ -39,14 +40,21 @@ public interface IGitConfiguration /// Configuration entry name. /// Configuration entry value. /// True if the value was found, false otherwise. - bool TryGetValue(string name, out string value); + bool TryGet(string name, out string value); /// /// Set the value of a configuration entry. /// /// Configuration entry name. /// Configuration entry value. - void SetValue(string name, string value); + void Set(string name, string value); + + /// + /// Add a new value for a configuration entry. + /// + /// Configuration entry name. + /// Configuration entry value. + void Add(string name, string value); /// /// Deletes a configuration entry from the highest level. @@ -54,6 +62,13 @@ public interface IGitConfiguration /// Configuration entry name. void Unset(string name); + /// + /// Get all value of a multivar configuration entry. + /// + /// Configuration entry name. + /// All values of the multivar configuration entry. + IEnumerable GetAll(string name); + /// /// Get all values of a multivar configuration entry. /// @@ -111,8 +126,8 @@ public void Enumerate(GitConfigurationEnumerationCallback cb) case 0: // OK break; default: - throw new Exception( - $"Failed to enumerate all Git configuration entries. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to enumerate config entries (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, "Failed to enumerate all Git configuration entries"); } IEnumerable entries = data.Split('\0').Where(x => !string.IsNullOrWhiteSpace(x)); @@ -128,10 +143,10 @@ public void Enumerate(GitConfigurationEnumerationCallback cb) } } - public bool TryGetValue(string name, out string value) + public bool TryGet(string name, out string value) { string level = GetLevelFilterArg(); - using (Process git = _git.CreateProcess($"config {level} {name}")) + using (Process git = _git.CreateProcess($"config {level} {QuoteCmdArg(name)}")) { git.Start(); git.WaitForExit(); @@ -144,8 +159,7 @@ public bool TryGetValue(string name, out string value) value = null; return false; default: // Error - _trace.WriteLine( - $"Failed to read Git configuration entry '{name}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to read Git configuration entry '{name}'. (exit={git.ExitCode}, level={_filterLevel})"); value = null; return false; } @@ -163,7 +177,7 @@ public bool TryGetValue(string name, out string value) } } - public void SetValue(string name, string value) + public void Set(string name, string value) { if (_filterLevel == GitConfigurationLevel.All) { @@ -171,7 +185,31 @@ public void SetValue(string name, string value) } string level = GetLevelFilterArg(); - using (Process git = _git.CreateProcess($"config {level} {name} \"{value}\"")) + using (Process git = _git.CreateProcess($"config {level} {QuoteCmdArg(name)} {QuoteCmdArg(value)}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + break; + default: + _trace.WriteLine($"Failed to set config entry '{name}' to value '{value}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to set Git configuration entry '{name}'"); + } + } + } + + public void Add(string name, string value) + { + if (_filterLevel == GitConfigurationLevel.All) + { + throw new InvalidOperationException("Must have a specific configuration level filter to add values."); + } + + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config {level} --add {QuoteCmdArg(name)} {QuoteCmdArg(value)}")) { git.Start(); git.WaitForExit(); @@ -181,8 +219,8 @@ public void SetValue(string name, string value) case 0: // OK break; default: - throw new Exception( - $"Failed to set Git configuration entry '{name}' to '{value}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to add config entry '{name}' with value '{value}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to add Git configuration entry '{name}'"); } } } @@ -195,18 +233,56 @@ public void Unset(string name) } string level = GetLevelFilterArg(); - using (Process git = _git.CreateProcess($"config {level} --unset {name}")) + using (Process git = _git.CreateProcess($"config {level} --unset {QuoteCmdArg(name)}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + case 5: // Trying to unset a value that does not exist + break; + default: + _trace.WriteLine($"Failed to unset config entry '{name}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to unset Git configuration entry '{name}'"); + } + } + } + + public IEnumerable GetAll(string name) + { + string level = GetLevelFilterArg(); + + var gitArgs = $"config --null {level} --get-all {QuoteCmdArg(name)}"; + + using (Process git = _git.CreateProcess(gitArgs)) { git.Start(); + + // TODO: don't read in all the data at once; stream it + string data = git.StandardOutput.ReadToEnd(); git.WaitForExit(); switch (git.ExitCode) { case 0: // OK + string[] entries = data.Split('\0'); + + // Because each line terminates with the \0 character, splitting leaves us with one + // bogus blank entry at the end of the array which we should ignore + for (var i = 0; i < entries.Length - 1; i++) + { + yield return entries[i]; + } + break; + + case 1: // No results break; + default: - throw new Exception( - $"Failed to unset Git configuration entry '{name}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to get all config entries '{name}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to get all Git configuration entries '{name}'"); } } } @@ -214,7 +290,14 @@ public void Unset(string name) public IEnumerable GetRegex(string nameRegex, string valueRegex) { string level = GetLevelFilterArg(); - using (Process git = _git.CreateProcess($"config --null {level} --get-regex {nameRegex} {valueRegex}")) + + var gitArgs = $"config --null {level} --get-regex {QuoteCmdArg(nameRegex)}"; + if (valueRegex != null) + { + gitArgs += $" {QuoteCmdArg(valueRegex)}"; + } + + using (Process git = _git.CreateProcess(gitArgs)) { git.Start(); // To avoid deadlocks, always read the output stream first and then wait @@ -228,8 +311,8 @@ public IEnumerable GetRegex(string nameRegex, string valueRegex) case 1: // No results break; default: - throw new Exception( - $"Failed to get Git configuration multi-valued entry '{nameRegex}' with value regex '{valueRegex}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to get all multivar regex '{nameRegex}' and value regex '{valueRegex}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to get Git configuration multi-valued entries with name regex '{nameRegex}'"); } string[] entries = data.Split('\0'); @@ -252,7 +335,13 @@ public void ReplaceAll(string name, string valueRegex, string value) } string level = GetLevelFilterArg(); - using (Process git = _git.CreateProcess($"config {level} --replace-all {name} {value} {valueRegex}")) + var gitArgs = $"config {level} --replace-all {QuoteCmdArg(name)} {QuoteCmdArg(value)}"; + if (valueRegex != null) + { + gitArgs += $" {QuoteCmdArg(valueRegex)}"; + } + + using (Process git = _git.CreateProcess(gitArgs)) { git.Start(); git.WaitForExit(); @@ -262,8 +351,8 @@ public void ReplaceAll(string name, string valueRegex, string value) case 0: // OK break; default: - throw new Exception( - $"Failed to set Git configuration multi-valued entry '{name}' with value regex '{valueRegex}' to value '{value}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to replace all multivar '{name}' and value regex '{valueRegex}' with new value '{value}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to replace all Git configuration multi-valued entries '{name}'"); } } } @@ -276,7 +365,13 @@ public void UnsetAll(string name, string valueRegex) } string level = GetLevelFilterArg(); - using (Process git = _git.CreateProcess($"config {level} --unset-all {name} {valueRegex}")) + var gitArgs = $"config {level} --unset-all {QuoteCmdArg(name)}"; + if (valueRegex != null) + { + gitArgs += $" {QuoteCmdArg(valueRegex)}"; + } + + using (Process git = _git.CreateProcess(gitArgs)) { git.Start(); git.WaitForExit(); @@ -287,12 +382,29 @@ public void UnsetAll(string name, string valueRegex) case 5: // Trying to unset a value that does not exist break; default: - throw new Exception( - $"Failed to unset all Git configuration multi-valued entries '{name}' with value regex '{valueRegex}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + _trace.WriteLine($"Failed to unset all multivar '{name}' with value regex '{valueRegex}' (exit={git.ExitCode}, level={_filterLevel})"); + throw CreateGitException(git, $"Failed to unset all Git configuration multi-valued entries '{name}'"); } } } + private Exception CreateGitException(Process git, string message) + { + var exceptionMessage = new StringBuilder(); + string gitMessage = git.StandardError.ReadToEnd(); + + if (!string.IsNullOrWhiteSpace(gitMessage)) + { + exceptionMessage.AppendLine(gitMessage); + } + + exceptionMessage.AppendLine(message); + exceptionMessage.AppendLine($"Exit code: '{git.ExitCode}'"); + exceptionMessage.AppendLine($"Configuration level: {_filterLevel}"); + + throw new Exception(exceptionMessage.ToString()); + } + private string GetLevelFilterArg() { switch (_filterLevel) @@ -310,6 +422,77 @@ private string GetLevelFilterArg() return null; } } + + public static string QuoteCmdArg(string str) + { + bool needsQuotes = string.IsNullOrEmpty(str); + var result = new StringBuilder(); + + for (int i = 0; i < (str?.Length ?? 0); i++) + { + switch (str![i]) + { + case '"': + result.Append("\\\""); + needsQuotes = true; + break; + + case ' ': + case '{': + case '*': + case '?': + case '\r': + case '\n': + case '\t': + case '\'': + result.Append(str[i]); + needsQuotes = true; + break; + + case '\\': + int end = i; + + // Copy all the '\'s in this run. + while (end < str.Length && str[end] == '\\') + { + result.Append('\\'); + end++; + } + + // If we ended the run of '\'s with a '"' then we need to double up the number of '\'s. + // The '"' will be escaped on the next pass of the loop. + // Also if we have reached the end of the string, and we need to book-end the result + // with double quotes ('"') we should escape all the '\'s to prevent ending on an + // escaped '"' in the result. + if (end < str.Length && str[end] == '"' || + end == str.Length && needsQuotes) + { + result.Append('\\', end - i); + } + + // Back-off one character + if (end > i) + { + end--; + } + + i = end; + break; + + default: + result.Append(str[i]); + break; + } + } + + if (needsQuotes) + { + result.Insert(0, '"'); + result.Append('"'); + } + + return result.ToString(); + } } public static class GitConfigurationExtensions @@ -321,9 +504,9 @@ public static class GitConfigurationExtensions /// Configuration object. /// Configuration entry name. /// Configuration entry value. - public static string GetValue(this IGitConfiguration config, string name) + public static string Get(this IGitConfiguration config, string name) { - if (!config.TryGetValue(name, out string value)) + if (!config.TryGet(name, out string value)) { throw new KeyNotFoundException($"Git configuration entry with the name '{name}' was not found."); } @@ -339,9 +522,9 @@ public static string GetValue(this IGitConfiguration config, string name) /// Configuration property name. /// A configuration entry with the specified key was not found. /// Configuration entry value. - public static string GetValue(this IGitConfiguration config, string section, string property) + public static string Get(this IGitConfiguration config, string section, string property) { - return GetValue(config, $"{section}.{property}"); + return Get(config, $"{section}.{property}"); } /// @@ -353,14 +536,14 @@ public static string GetValue(this IGitConfiguration config, string section, str /// Configuration property name. /// A configuration entry with the specified key was not found. /// Configuration entry value. - public static string GetValue(this IGitConfiguration config, string section, string scope, string property) + public static string Get(this IGitConfiguration config, string section, string scope, string property) { if (scope is null) { - return GetValue(config, section, property); + return Get(config, section, property); } - return GetValue(config, $"{section}.{scope}.{property}"); + return Get(config, $"{section}.{scope}.{property}"); } /// @@ -371,9 +554,9 @@ public static string GetValue(this IGitConfiguration config, string section, str /// Configuration property name. /// Configuration entry value. /// True if the value was found, false otherwise. - public static bool TryGetValue(this IGitConfiguration config, string section, string property, out string value) + public static bool TryGet(this IGitConfiguration config, string section, string property, out string value) { - return config.TryGetValue($"{section}.{property}", out value); + return config.TryGet($"{section}.{property}", out value); } /// @@ -385,14 +568,14 @@ public static bool TryGetValue(this IGitConfiguration config, string section, st /// Configuration property name. /// Configuration entry value. /// True if the value was found, false otherwise. - public static bool TryGetValue(this IGitConfiguration config, string section, string scope, string property, out string value) + public static bool TryGet(this IGitConfiguration config, string section, string scope, string property, out string value) { if (scope is null) { - return TryGetValue(config, section, property, out value); + return TryGet(config, section, property, out value); } - return config.TryGetValue($"{section}.{scope}.{property}", out value); + return config.TryGet($"{section}.{scope}.{property}", out value); } } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Settings.cs b/src/shared/Microsoft.Git.CredentialManager/Settings.cs index 211eae153..dc1ee19e4 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Settings.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Settings.cs @@ -247,7 +247,7 @@ public IEnumerable GetSettingValues(string envarName, string section, st * property = value * */ - if (config.TryGetValue($"{section}.{property}", out value)) + if (config.TryGet($"{section}.{property}", out value)) { yield return value; } @@ -311,7 +311,7 @@ public bool IsInteractionAllowed TryGetSetting(KnownEnvars.GcmAuthority, GitCredCfg.SectionName, GitCredCfg.Authority, out string authority) ? authority : null; public bool IsWindowsIntegratedAuthenticationEnabled => - TryGetSetting(KnownEnvars.GcmAllowWia, GitCredCfg.SectionName, GitCredCfg.AllowWia, out string value) && value.ToBooleanyOrDefault(true); + !TryGetSetting(KnownEnvars.GcmAllowWia, GitCredCfg.SectionName, GitCredCfg.AllowWia, out string value) || value.ToBooleanyOrDefault(true); public bool IsCertificateVerificationEnabled { diff --git a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs index 47173a58e..2925d9021 100644 --- a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs +++ b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs @@ -25,8 +25,8 @@ public TestGitConfiguration(IDictionary> config = null) /// public string this[string key] { - get => TryGetValue(key, out string value) ? value : null; - set => SetValue(key, value); + get => TryGet(key, out string value) ? value : null; + set => Set(key, value); } #region IGitConfiguration @@ -45,7 +45,7 @@ public void Enumerate(GitConfigurationEnumerationCallback cb) } } - public bool TryGetValue(string name, out string value) + public bool TryGet(string name, out string value) { if (Dictionary.TryGetValue(name, out var values)) { @@ -66,7 +66,7 @@ public bool TryGetValue(string name, out string value) return false; } - public void SetValue(string name, string value) + public void Set(string name, string value) { if (!Dictionary.TryGetValue(name, out IList values)) { @@ -90,6 +90,17 @@ public void SetValue(string name, string value) } } + public void Add(string name, string value) + { + if (!Dictionary.TryGetValue(name, out IList values)) + { + values = new List(); + Dictionary[name] = values; + } + + values.Add(value); + } + public void Unset(string name) { // TODO: simulate git @@ -101,11 +112,24 @@ public void Unset(string name) Dictionary.Remove(name); } + public IEnumerable GetAll(string name) + { + if (Dictionary.TryGetValue(name, out IList values)) + { + return values; + } + + return Enumerable.Empty(); + } + public IEnumerable GetRegex(string nameRegex, string valueRegex) { - if (Dictionary.TryGetValue(nameRegex, out IList values)) + foreach (string key in Dictionary.Keys) { - return values.Where(x => Regex.IsMatch(x, valueRegex)); + if (Regex.IsMatch(key, nameRegex)) + { + return Dictionary[key].Where(x => Regex.IsMatch(x, valueRegex)); + } } return Enumerable.Empty(); diff --git a/src/shared/TestInfrastructure/Objects/TestSettings.cs b/src/shared/TestInfrastructure/Objects/TestSettings.cs index d6e18b021..408bc5cd6 100644 --- a/src/shared/TestInfrastructure/Objects/TestSettings.cs +++ b/src/shared/TestInfrastructure/Objects/TestSettings.cs @@ -52,7 +52,7 @@ public bool TryGetSetting(string envarName, string section, string property, out return true; } - if (GitConfiguration?.TryGetValue(section, property, out value) ?? false) + if (GitConfiguration?.TryGet(section, property, out value) ?? false) { return true; } diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index 4e2f1b79b..fe95782d7 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -18,7 +18,7 @@ - + all @@ -29,7 +29,7 @@ before we attempt to sign any files or validate they exist. --> - + Microsoft400 false @@ -47,10 +47,13 @@ - $(NuGetPackageRoot)Tools.InnoSetup\5.6.1\tools\ISCC.exe /DPayloadDir=$(PayloadPath) Setup.iss /O$(OutputPath) + $(NuGetPackageRoot)Tools.InnoSetup\6.0.5\tools\ISCC.exe /DPayloadDir=$(PayloadPath) /DInstallTarget=system Setup.iss /O$(OutputPath) + $(NuGetPackageRoot)Tools.InnoSetup\6.0.5\tools\ISCC.exe /DPayloadDir=$(PayloadPath) /DInstallTarget=user Setup.iss /O$(OutputPath) - - + + + + @@ -59,4 +62,4 @@ - \ No newline at end of file + diff --git a/src/windows/Installer.Windows/Setup.iss b/src/windows/Installer.Windows/Setup.iss index 55f6d5b37..c46e6175e 100644 --- a/src/windows/Installer.Windows/Setup.iss +++ b/src/windows/Installer.Windows/Setup.iss @@ -1,43 +1,63 @@ -; This script requires Inno Setup Compiler 5.6.1 or later to compile +; This script requires Inno Setup Compiler 6.0.0 or later to compile ; The Inno Setup Compiler (and IDE) can be found at http://www.jrsoftware.org/isinfo.php ; General documentation on how to use InnoSetup scripts: http://www.jrsoftware.org/ishelp/index.php ; Ensure minimum Inno Setup tooling version -#if VER < EncodeVer(5,6,1) - #error Update your Inno Setup version (5.6.1 or newer) +#if VER < EncodeVer(6,0,0) + #error Update your Inno Setup version (6.0.0 or newer) #endif #ifndef PayloadDir #error Payload directory path property 'PayloadDir' must be specified #endif -#ifnexist PayloadDir + "\git-credential-manager-core.exe" - #error Payload files are missing +#ifndef InstallTarget + #error Installer target property 'InstallTarget' must be specifed +#endif + +#if InstallTarget == "user" + #define GcmAppId "{{aa76d31d-432c-42ee-844c-bc0bc801cef3}}" + #define GcmLongName "Git Credential Manager Core (User)" + #define GcmSetupExe "gcmcoreuser" + #define GcmConfigureCmdArgs "--user" +#elif InstallTarget == "system" + #define GcmAppId "{{fdfae50a-1bc1-4ead-9228-1e1c275e8d12}}" + #define GcmLongName "Git Credential Manager Core" + #define GcmSetupExe "gcmcore" + #define GcmConfigureCmdArgs "--system" +#else + #error Installer target property 'InstallTarget' must be 'user' or 'system' #endif ; Define core properties -#define GcmName "Git Credential Manager Core" +#define GcmShortName "Git Credential Manager Core" #define GcmPublisher "Microsoft Corporation" #define GcmPublisherUrl "https://www.microsoft.com" -#define GcmCopyright "Copyright (c) Microsoft 2019" -#define GcmUrl "https://github.com/microsoft/Git-Credential-Manager-Core" +#define GcmCopyright "Copyright (c) Microsoft 2020" +#define GcmUrl "https://aka.ms/gcmcore" #define GcmReadme "https://github.com/microsoft/Git-Credential-Manager-Core/blob/master/README.md" #define GcmRepoRoot "..\..\.." #define GcmAssets GcmRepoRoot + "\assets" +#define GcmExe "git-credential-manager-core.exe" +#define GcmArch "x86" + +#ifnexist PayloadDir + "\" + GcmExe + #error Payload files are missing +#endif ; Generate the GCM version version from the CLI executable #define VerMajor #define VerMinor #define VerBuild #define VerRevision -#expr ParseVersion(PayloadDir + "\git-credential-manager-core.exe", VerMajor, VerMinor, VerBuild, VerRevision) +#expr ParseVersion(PayloadDir + "\" + GcmExe, VerMajor, VerMinor, VerBuild, VerRevision) #define GcmVersion str(VerMajor) + "." + str(VerMinor) + "." + str(VerBuild) + "." + str(VerRevision) [Setup] -AppId={{fdfae50a-1bc1-4ead-9228-1e1c275e8d12}} -AppName={#GcmName} +AppId={#GcmAppId} +AppName={#GcmLongName} AppVersion={#GcmVersion} -AppVerName={#GcmName} {#GcmVersion} +AppVerName={#GcmLongName} {#GcmVersion} AppPublisher={#GcmPublisher} AppPublisherURL={#GcmPublisherUrl} AppSupportURL={#GcmUrl} @@ -47,13 +67,13 @@ AppCopyright={#GcmCopyright} AppReadmeFile={#GcmReadme} VersionInfoVersion={#GcmVersion} LicenseFile={#GcmRepoRoot}\LICENSE -OutputBaseFilename=gcmcore-win-x86-{#GcmVersion} -DefaultDirName={pf}\{#GcmName} +OutputBaseFilename={#GcmSetupExe}-win-{#GcmArch}-{#GcmVersion} +DefaultDirName={autopf}\{#GcmShortName} Compression=lzma2 SolidCompression=yes MinVersion=6.1.7600 DisableDirPage=yes -UninstallDisplayIcon={app}\git-credential-manager-core.exe +UninstallDisplayIcon={app}\{#GcmExe} SetupIconFile={#GcmAssets}\gcmicon.ico WizardImageFile={#GcmAssets}\gcmicon128.bmp WizardSmallImageFile={#GcmAssets}\gcmicon64.bmp @@ -61,6 +81,9 @@ WizardStyle=modern WizardImageStretch=no WindowResizable=no ChangesEnvironment=yes +#if InstallTarget == "user" + PrivilegesRequired=lowest +#endif [Languages] Name: english; MessagesFile: "compiler:Default.isl"; @@ -72,10 +95,10 @@ Name: full; Description: "Full installation"; Flags: iscustom; ; No individual components [Run] -Filename: "{app}\git-credential-manager-core.exe"; Parameters: "configure"; Flags: runhidden +Filename: "{app}\{#GcmExe}"; Parameters: "configure {#GcmConfigureCmdArgs}"; Flags: runhidden [UninstallRun] -Filename: "{app}\git-credential-manager-core.exe"; Parameters: "unconfigure"; Flags: runhidden +Filename: "{app}\{#GcmExe}"; Parameters: "unconfigure {#GcmConfigureCmdArgs}"; Flags: runhidden [Files] Source: "{#PayloadDir}\Atlassian.Bitbucket.dll"; DestDir: "{app}"; Flags: ignoreversion @@ -95,3 +118,18 @@ Source: "{#PayloadDir}\Microsoft.IdentityModel.JsonWebTokens.dll"; DestDir: Source: "{#PayloadDir}\Microsoft.IdentityModel.Logging.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Microsoft.IdentityModel.Tokens.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Newtonsoft.Json.dll"; DestDir: "{app}"; Flags: ignoreversion + +[Code] +// Don't allow installing conflicting architectures +function InitializeSetup(): Boolean; +begin + Result := True; + + #if InstallTarget == "user" + if not WizardSilent() and IsAdmin() then begin + if MsgBox('This User Installer is not meant to be run as an Administrator. If you would like to install Git Credential Manager Core for all users in this system, download the System Installer instead from https://aka.ms/gcmcore-latest. Are you sure you want to continue?', mbError, MB_OKCANCEL) = IDCANCEL then begin + Result := False; + end; + end; + #endif +end;