diff --git a/.azure-pipelines/continuous-integration.yml b/.azure-pipelines/continuous-integration.yml index 87441df81..5e40bf02d 100644 --- a/.azure-pipelines/continuous-integration.yml +++ b/.azure-pipelines/continuous-integration.yml @@ -22,3 +22,11 @@ jobs: steps: - template: templates/osx/compile.yml - template: templates/osx/pack.unsigned.yml + +- job: ubuntu1804_x86_64 + displayName: Ubuntu 18.04 LTS x86_64 + pool: + vmImage: ubuntu-18.04 + steps: + - template: templates/linux/compile.yml + - template: templates/linux/pack.unsigned.yml diff --git a/.azure-pipelines/pull-request.yml b/.azure-pipelines/pull-request.yml index 75bbd4600..067c69875 100644 --- a/.azure-pipelines/pull-request.yml +++ b/.azure-pipelines/pull-request.yml @@ -1,5 +1,6 @@ -trigger: none - +pr: + - master + - release variables: configuration: Release @@ -22,3 +23,11 @@ jobs: steps: - template: templates/osx/compile.yml - template: templates/osx/pack.unsigned.yml + +- job: ubuntu1804_x86_64 + displayName: Ubuntu 18.04 LTS x86_64 + pool: + vmImage: ubuntu-18.04 + steps: + - template: templates/linux/compile.yml + - template: templates/linux/pack.unsigned.yml diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 5a6b25336..1047e1198 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -59,3 +59,11 @@ jobs: vmImage: $(osxImage) steps: - template: templates/osx/pack.signed/step5-dist.yml + +- job: ubuntu1804_x86_64 + displayName: Ubuntu 18.04 LTS x86_64 + pool: + vmImage: ubuntu-18.04 + steps: + - template: templates/linux/compile.yml + - template: templates/linux/pack.unsigned.yml diff --git a/.azure-pipelines/templates/linux/compile.yml b/.azure-pipelines/templates/linux/compile.yml new file mode 100644 index 000000000..032ef5ca4 --- /dev/null +++ b/.azure-pipelines/templates/linux/compile.yml @@ -0,0 +1,22 @@ +steps: + - task: UseDotNet@2 + displayName: Use .NET Core SDK 3.1.x + inputs: + packageType: sdk + version: 3.1.x + + - task: DotNetCoreCLI@2 + displayName: Compile common code + inputs: + command: build + projects: 'Git-Credential-Manager.sln' + arguments: '--configuration=Linux$(configuration)' + + - task: DotNetCoreCLI@2 + displayName: Run common unit tests + inputs: + command: test + projects: 'Git-Credential-Manager.sln' + arguments: '--configuration=Linux$(configuration)' + publishTestResults: true + testRunTitle: 'Unit tests (Linux)' diff --git a/.azure-pipelines/templates/linux/pack.unsigned.yml b/.azure-pipelines/templates/linux/pack.unsigned.yml new file mode 100644 index 000000000..08f9ccffd --- /dev/null +++ b/.azure-pipelines/templates/linux/pack.unsigned.yml @@ -0,0 +1,12 @@ +steps: + - script: | + mkdir -p "$(Build.StagingDirectory)/publish/" + cp "out/linux/Packaging.Linux/tar/$(configuration)/"*.tar.gz "$(Build.StagingDirectory)/publish/" + cp "out/linux/Packaging.Linux/deb/$(configuration)/"*.deb "$(Build.StagingDirectory)/publish/" + displayName: Prepare final build artifacts + + - task: PublishPipelineArtifact@0 + displayName: Publish unsigned installer artifacts + inputs: + artifactName: 'Installer.Linux.Unsigned' + targetPath: '$(Build.StagingDirectory)/publish' diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml new file mode 100644 index 000000000..68ddf2864 --- /dev/null +++ b/.github/workflows/build-installers.yml @@ -0,0 +1,36 @@ +name: Build-Installers + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + linux: + name: "Linux" + + 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: Installers + path: | + out/linux/Packaging.Linux/deb/Release/*.deb + out/linux/Packaging.Linux/tar/Release/*.tar.gz diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 000000000..7cec30fc9 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,44 @@ +name: GCM-Core + +on: + push: + branches: [ master, linux ] + pull_request: + branches: [ master, linux ] + +jobs: + validate_gcm: + name: "CI" + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-16.04, ubuntu-18.04, ubuntu-20.04, windows-2019, macos-10.15] + + 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 + + - name: Build Windows + if: contains(matrix.os, 'windows') + run: dotnet build --configuration WindowsRelease + + - name: Build Linux + if: contains(matrix.os, 'ubuntu') + run: dotnet build --configuration LinuxRelease + + - name: Build macOS + if: contains(matrix.os, 'macos') + run: dotnet build --configuration MacRelease + + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.github/workflows/release-homebrew.yaml b/.github/workflows/release-homebrew.yaml index eb1969bfd..b847388f9 100644 --- a/.github/workflows/release-homebrew.yaml +++ b/.github/workflows/release-homebrew.yaml @@ -1,40 +1,18 @@ name: "release-homebrew" on: release: - types: [published] + types: [released] jobs: release: runs-on: ubuntu-latest steps: - - name: Get macOS package version - uses: actions/github-script@0.3.0 - id: version - with: - github-token: ${{secrets.GITHUB_TOKEN}} - result-encoding: string - script: | - const { data: releases } = await github.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo - }); - const release = releases.find(x => x.tag_name === process.env.GITHUB_REF); - if (!release) { - throw new Error(`unable to find release with tag '${process.env.GITHUB_REF}'`); - } - const regex = /gcmcore-osx-(.*)\.pkg/; - const asset = release.assets.find(x => regex.test(x.name)); - if (!asset) { - throw new Error(`unable to find asset matching '${regex}'`); - } - const matches = asset.name.match(regex); - const version = matches[1]; - return version; - name: Update Homebrew tap - uses: mjcheetham/update-homebrew@v1 + uses: mjcheetham/update-homebrew@v1.1 with: token: ${{ secrets.HOMEBREW_TOKEN }} tap: microsoft/git name: git-credential-manager-core type: cask - version: ${{ steps.version.outputs.result }} + releaseAsset: gcmcore-osx-(.*)\.pkg + alwaysUsePullRequest: true diff --git a/Directory.Build.props b/Directory.Build.props index d1cce39d6..1a2ed4596 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ windows osx @@ -23,7 +23,7 @@ - 2.3.38 + 3.2.7-beta all diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln index c5f79bbca..878a08a93 100644 --- a/Git-Credential-Manager.sln +++ b/Git-Credential-Manager.sln @@ -56,6 +56,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{83BD5957-A docs\migration.md = docs\migration.md docs\netconfig.md = docs\netconfig.md docs\usage.md = docs\usage.md + docs\architecture.md = docs\architecture.md + docs\hostprovider.md = docs\hostprovider.md + docs\linuxcredstores.md = docs\linuxcredstores.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.UI.Windows", "src\windows\GitHub.UI.Windows\GitHub.UI.Windows.csproj", "{4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}" @@ -68,6 +71,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.UI.Windows", "src\wi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atlassian.Bitbucket.UI.Windows", "src\windows\Atlassian.Bitbucket.UI.Windows\Atlassian.Bitbucket.UI.Windows.csproj", "{D34D31DF-B44A-45D3-9B39-73573077BAE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Packaging.Linux", "src\linux\Packaging.Linux\Packaging.Linux.csproj", "{AD2A935F-3720-4802-8119-6A9B35B254DF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "linux", "linux", "{8F9D7E67-7DD7-4E32-9134-423281AF00E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -76,6 +83,8 @@ Global Release|Any CPU = Release|Any CPU WindowsDebug|Any CPU = WindowsDebug|Any CPU WindowsRelease|Any CPU = WindowsRelease|Any CPU + LinuxDebug|Any CPU = LinuxDebug|Any CPU + LinuxRelease|Any CPU = LinuxRelease|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -90,6 +99,10 @@ Global {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {31BCFC70-B767-4274-873F-1A076D422FC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {31BCFC70-B767-4274-873F-1A076D422FC3}.Debug|Any CPU.Build.0 = Debug|Any CPU {31BCFC70-B767-4274-873F-1A076D422FC3}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -102,6 +115,10 @@ Global {31BCFC70-B767-4274-873F-1A076D422FC3}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {31BCFC70-B767-4274-873F-1A076D422FC3}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {31BCFC70-B767-4274-873F-1A076D422FC3}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {31BCFC70-B767-4274-873F-1A076D422FC3}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {31BCFC70-B767-4274-873F-1A076D422FC3}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {31BCFC70-B767-4274-873F-1A076D422FC3}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {31BCFC70-B767-4274-873F-1A076D422FC3}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -114,6 +131,10 @@ Global {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.Debug|Any CPU.Build.0 = Debug|Any CPU {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -126,6 +147,10 @@ Global {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.Debug|Any CPU.Build.0 = Debug|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -138,6 +163,10 @@ Global {97DC6241-1240-4A85-8035-F8404A983A82}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -150,6 +179,10 @@ Global {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {3C840B06-A595-4FD9-9A76-56CD45B14780}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3C840B06-A595-4FD9-9A76-56CD45B14780}.Debug|Any CPU.Build.0 = Debug|Any CPU {3C840B06-A595-4FD9-9A76-56CD45B14780}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -162,6 +195,10 @@ Global {3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {3C840B06-A595-4FD9-9A76-56CD45B14780}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {3C840B06-A595-4FD9-9A76-56CD45B14780}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {3C840B06-A595-4FD9-9A76-56CD45B14780}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {3C840B06-A595-4FD9-9A76-56CD45B14780}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {3E524EA8-D31A-4394-997C-14B522E3D6FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3E524EA8-D31A-4394-997C-14B522E3D6FD}.Debug|Any CPU.Build.0 = Debug|Any CPU {3E524EA8-D31A-4394-997C-14B522E3D6FD}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -174,6 +211,10 @@ Global {3E524EA8-D31A-4394-997C-14B522E3D6FD}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {3E524EA8-D31A-4394-997C-14B522E3D6FD}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {3E524EA8-D31A-4394-997C-14B522E3D6FD}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {3E524EA8-D31A-4394-997C-14B522E3D6FD}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {3E524EA8-D31A-4394-997C-14B522E3D6FD}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {3E524EA8-D31A-4394-997C-14B522E3D6FD}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {3E524EA8-D31A-4394-997C-14B522E3D6FD}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {206430B1-CEED-4C84-8D49-D0A399632202}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {206430B1-CEED-4C84-8D49-D0A399632202}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {206430B1-CEED-4C84-8D49-D0A399632202}.MacDebug|Any CPU.Build.0 = Debug|Any CPU @@ -182,6 +223,8 @@ Global {206430B1-CEED-4C84-8D49-D0A399632202}.Release|Any CPU.ActiveCfg = Release|Any CPU {206430B1-CEED-4C84-8D49-D0A399632202}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU {206430B1-CEED-4C84-8D49-D0A399632202}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {206430B1-CEED-4C84-8D49-D0A399632202}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {206430B1-CEED-4C84-8D49-D0A399632202}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.MacDebug|Any CPU.Build.0 = Debug|Any CPU @@ -190,6 +233,8 @@ Global {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.Release|Any CPU.ActiveCfg = Release|Any CPU {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {74FA0AA4-B5C1-4F3B-B182-277FC2D50715}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {85903170-9E52-4B53-A6E4-3F416F684FAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {85903170-9E52-4B53-A6E4-3F416F684FAE}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {85903170-9E52-4B53-A6E4-3F416F684FAE}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU @@ -198,6 +243,8 @@ Global {85903170-9E52-4B53-A6E4-3F416F684FAE}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {85903170-9E52-4B53-A6E4-3F416F684FAE}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {85903170-9E52-4B53-A6E4-3F416F684FAE}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {85903170-9E52-4B53-A6E4-3F416F684FAE}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {85903170-9E52-4B53-A6E4-3F416F684FAE}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU @@ -206,6 +253,8 @@ Global {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {8DBBAB0A-970D-4BE3-958C-8CDC92F76549}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU @@ -214,6 +263,8 @@ Global {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -226,6 +277,10 @@ Global {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacDebug|Any CPU.Build.0 = Debug|Any CPU {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacRelease|Any CPU.ActiveCfg = Debug|Any CPU {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacRelease|Any CPU.Build.0 = Debug|Any CPU + {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {B49881A6-E734-490E-8EA7-FB0D9E296CFB}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -238,6 +293,10 @@ Global {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacDebug|Any CPU.Build.0 = Debug|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacRelease|Any CPU.ActiveCfg = Debug|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacRelease|Any CPU.Build.0 = Debug|Any CPU + {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -246,6 +305,8 @@ Global {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {D34D31DF-B44A-45D3-9B39-73573077BAE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D34D31DF-B44A-45D3-9B39-73573077BAE0}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU {D34D31DF-B44A-45D3-9B39-73573077BAE0}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -254,6 +315,18 @@ Global {D34D31DF-B44A-45D3-9B39-73573077BAE0}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU {D34D31DF-B44A-45D3-9B39-73573077BAE0}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {D34D31DF-B44A-45D3-9B39-73573077BAE0}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {D34D31DF-B44A-45D3-9B39-73573077BAE0}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {D34D31DF-B44A-45D3-9B39-73573077BAE0}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -279,6 +352,8 @@ Global {025E5329-A0B1-4BA9-9203-B70B44A5F9E0} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} {D34D31DF-B44A-45D3-9B39-73573077BAE0} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} + {8F9D7E67-7DD7-4E32-9134-423281AF00E9} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E} + {AD2A935F-3720-4802-8119-6A9B35B254DF} = {8F9D7E67-7DD7-4E32-9134-423281AF00E9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EF9FC65-E6BA-45D4-A455-262A9EA4366B} diff --git a/docs/architecture.md b/docs/architecture.md index 750e21230..69da1fd4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,11 +96,11 @@ specific host provider. This was done to allow any service that may wish to in the future integrate with Microsoft Accounts or Azure Active Directory can make use of this reusable authentication component. -Since MSAL.NET includes embedded GUI on Windows (when targeting .NET Frameonly +Since MSAL.NET includes embedded GUI on Windows (when targeting .NET Framework only - see note above) we have no helper executable on Windows. However, on macOS the `MicrosoftAuthentication` component shells out to a native macOS helper that completely takes over all authentication flows using the older ADAL -Objective-C libary. This was done because MSAL.NET does not offer the same level +Objective-C library. This was done because MSAL.NET does not offer the same level of integration for [MDM](https://en.wikipedia.org/wiki/Mobile_device_management) purposes, as well as lacking an embedded UI on non-Windows platforms. As MSAL.NET continues to evolve we hope to replace the ADAL/macOS helper @@ -164,7 +164,7 @@ instance of the provider to the `Application` object via the `RegisterProvider` method [in `Microsoft.Git.CredentialManager.Program`](../src/shared/Git-Credential-Manager/Program.cs). The `GenericHostProvider` is registered last so that it can handle all other HTTP-based remotes as a catch-all, and provide basic username/password auth and -detect the presense of Windows Integrated Authentication (Kerberos, NTLM, +detect the presence of Windows Integrated Authentication (Kerberos, NTLM, Negotiate) support (1). For each invocation of GCM Core, the first argument on the command-line is @@ -174,7 +174,7 @@ from Git (over standard input) is deserialized and the command is executed (2). The `Get|Store|EraseCommand`s consult the host provider registry for the most appropriate host provider. The default registry implementation select the a host provider by asking each registered provider in turn if they understand the -request. The provider selection can be overriden by the user via the +request. The provider selection can be overridden by the user via the [`credential.provider`](configuration.md#credentialprovider) or [`GCM_PROVIDER`](environment.md#GCM_PROVIDER) configuration and environment variable respectively (3)). @@ -196,21 +196,27 @@ directly implement the interface they can also derive from the `HostProvider` abstract class (which itself implements the `IHostProvider` interface). The `HostProvider` abstract class implements the -`Get|Store|EraseCredentialAsync` methods and instead has a -`GenerateCredentialAsync` and `GetCredentialKey` abstract methods. Calls to -`get`, `store`, or `erase` result in first a call to `GetCredentialKey` which -should return a stable and unique "key" for the request. This forms the key for -any stored credential in the credential store. During a `get` operation the -credential store is queried for an existing credential with the computed key. +`Get|Store|EraseCredentialAsync` methods and instead has the +`GenerateCredentialAsync` abstract method, and the `GetServiceName` virtual +method. Calls to `get`, `store`, or `erase` result in first a call to +`GetServiceName` which should return a stable and unique value for the provider +and request. This value forms part of the attributes associated with any stored +credential in the credential store. During a `get` operation the +credential store is queried for an existing credential with such service name. If a credential is found it is returned immediately. Similarly, calls to `store` and `erase` are handles automatically to store credentials against, and erase -credentials matching the computed key. Methods are implemented as `virtual` +credentials matching the service name. Methods are implemented as `virtual` meaning you can always override this behaviour, for example to clear other custom caches on an `erase` request, without having to reimplement the lookup/store credential logic. +The default implementation of `GetServiceName` is usually sufficient for most +providers. It returns the computed remote URL (without a trailing slash) from +the input arguments from Git - `://[/]` - no username is +included even if present. + Host providers are queried in turn (registration order) via the -`IHostProvider.IsSupported` method and passed the input recieved from Git. If +`IHostProvider.IsSupported` method and passed the input received from Git. If the provider recognises the request, for example by a matching known host name, they can return `true`. If the provider wants to cancel and abort an authentication request, for example if this is a HTTP (not HTTPS) request for a @@ -266,7 +272,7 @@ caught, a non-zero exit code returned, and the error message printed with the "fatal:" prefix. For errors originating from interop/native code, you should throw an exception of the `InteropException` type. Error messages in exceptions should be human readable. When there is a known or user-fixable issue, -instructions on how to self-rememdy the issue, or links to relevant +instructions on how to self-remedy the issue, or links to relevant documentation should be given. Warnings can be emitted over the standard error stream diff --git a/docs/configuration.md b/docs/configuration.md index 179507d9d..389b39ca3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,7 +16,7 @@ GCM Core will only be used by Git if it is installed and configured (`credential > `credential.microsoft.visualstudio.com.namespace` is more specific than `credential.visualstudio.com.namespace`, which is more specific than `credential.namespace`. -In the examples above, the `credential.namespace` setting would affect any remote repository; the `credential.visualstudio.com.namespace` would affect any remote repository in the domain, and/or any subdomain (including `www.`) of, 'visualstudio.com'; where as the the `credential.microsoft.visualstudio.com.namespace` setting would only be applied to remote repositories hosted at 'microsoft.visualstudio.com'. +In the examples above, the `credential.namespace` setting would affect any remote repository; the `credential.visualstudio.com.namespace` would affect any remote repository in the domain, and/or any subdomain (including `www.`) of, 'visualstudio.com'; where as the `credential.microsoft.visualstudio.com.namespace` setting would only be applied to remote repositories hosted at 'microsoft.visualstudio.com'. For the complete list of settings GCM Core understands, see the list below. @@ -26,7 +26,7 @@ For the complete list of settings GCM Core understands, see the list below. Permit or disable GCM Core from interacting with the user (showing GUI or TTY prompts). If interaction is required but has been disabled, an error is returned. -This can be helpful when using GCM Core in headless and unattended environments, such as build servers, where it would be preferable to fail than to hang indefinately waiting for a non-existent user. +This can be helpful when using GCM Core in headless and unattended environments, such as build servers, where it would be preferable to fail than to hang indefinitely waiting for a non-existent user. To disable interactivity set this to `false` or `0`. @@ -168,3 +168,61 @@ git config --global credential.gitHubAuthModes "oauth basic" ``` **Also see: [GCM_GITHUB_AUTHMODES](environment.md#GCM_GITHUB_AUTHMODES)** + +--- + +### credential.namespace + +Use a custom namespace prefix for credentials read and written in the OS credential store. +Credentials will be stored in the format `{namespace}:{service}`. + +Defaults to the value `git`. + +#### Example + +```shell +git config --global credential.namespace "my-namespace" +``` + +**Also see: [GCM_NAMESPACE](environment.md#GCM_NAMESPACE)** + +--- + +### credential.credentialStore + +Select the type of credential store to use on supported platforms. + +Default value is unset. + +**Note:** This setting is only supported on Linux platforms. Setting this value on Windows and macOS has no effect. See more information about configuring secret stores on Linux [here](linuxcredstores.md). + +Value|Credential Store +-|- +_(unset)_|(error) +`secretservice`|[freedesktop.org Secret Service API](https://specifications.freedesktop.org/secret-service/) via [libsecret](https://wiki.gnome.org/Projects/Libsecret) (requires a graphical interface to unlock secret collections). +`gpg`|Use GPG to store encrypted files that are compatible with the [`pass` utility](https://www.passwordstore.org/) (requires GPG and `pass` to initialize the store). +`plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`credential.plaintextStorePath`](#credentialplaintextstorepath). + +##### Example + +```bash +git config --global credential.credentialStore gpg +``` + +**Also see: [GCM_CREDENTIAL_STORE](environment.md#GCM_CREDENTIAL_STORE)** + +--- + +### credential.plaintextStorePath + +Specify a custom directory to store plaintext credential files in when [`credential.credentialStore`](#credentialcredentialstore) is set to `plaintext`. + +Defaults to the value `~/.gcm/store`. + +#### Example + +```shell +git config --global credential.plaintextStorePath /mnt/external-drive/credentials +``` + +**Also see: [GCM_PLAINTEXT_STORE_PATH](environment.md#GCM_PLAINTEXT_STORE_PATH)** diff --git a/docs/environment.md b/docs/environment.md index c60c42c1c..d48ddeeee 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -41,7 +41,7 @@ _No configuration equivalent._ ### GCM_TRACE_SECRETS -Enables tracing of secret and senstive information, which is by default masked in trace output. +Enables tracing of secret and sensitive information, which is by default masked in trace output. Requires that `GCM_TRACE` is also enabled. #### Example @@ -125,7 +125,7 @@ _No configuration equivalent._ Permit or disable GCM Core from interacting with the user (showing GUI or TTY prompts). If interaction is required but has been disabled, an error is returned. -This can be helpful when using GCM Core in headless and unattended environments, such as build servers, where it would be preferable to fail than to hang indefinately waiting for a non-existent user. +This can be helpful when using GCM Core in headless and unattended environments, such as build servers, where it would be preferable to fail than to hang indefinitely waiting for a non-existent user. To disable interactivity set this to `false` or `0`. @@ -309,3 +309,67 @@ export GCM_GITHUB_AUTHMODES="oauth basic" ``` **Also see: [credential.gitHubAuthModes](configuration.md#credentialgitHubAuthModes)** + +--- + +### GCM_NAMESPACE + +Use a custom namespace prefix for credentials read and written in the OS credential store. +Credentials will be stored in the format `{namespace}:{service}`. + +Defaults to the value `git`. + +##### Windows + +```batch +SET GCM_NAMESPACE="my-namespace" +``` + +##### macOS/Linux + +```bash +export GCM_NAMESPACE="my-namespace" +``` + +**Also see: [credential.namespace](configuration.md#credentialnamespace)** + +--- + +### GCM_CREDENTIAL_STORE + +Select the type of credential store to use on supported platforms. + +Default value is unset. + +**Note:** This setting is only supported on Linux platforms. Setting this value on Windows and macOS has no effect. See more information about configuring secret stores on Linux [here](linuxcredstores.md). + +Value|Credential Store +-|- +_(unset)_|(error) +`secretservice`|[freedesktop.org Secret Service API](https://specifications.freedesktop.org/secret-service/) via [libsecret](https://wiki.gnome.org/Projects/Libsecret) (requires a graphical interface to unlock secret collections). +`gpg`|Use GPG to store encrypted files that are compatible with the [`pass` utility](https://www.passwordstore.org/) (requires GPG and `pass` to initialize the store). +`plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`GCM_PLAINTEXT_STORE_PATH`](#GCM_PLAINTEXT_STORE_PATH). + +##### Linux + +```bash +export GCM_CREDENTIAL_STORE="gpg" +``` + +**Also see: [credential.credentialStore](configuration.md#credentialcredentialstore)** + +--- + +### GCM_PLAINTEXT_STORE_PATH + +Specify a custom directory to store plaintext credential files in when [`GCM_CREDENTIAL_STORE`](#GCM_CREDENTIAL_STORE) is set to `plaintext`. + +Defaults to the value `~/.gcm/store`. + +#### Linux + +```shell +export GCM_PLAINTEXT_STORE_PATH=/mnt/external-drive/credentials +``` + +**Also see: [credential.plaintextStorePath](configuration.md#credentialplaintextstorepath)** diff --git a/docs/faq.md b/docs/faq.md index 53a18ce27..5f2cb2a72 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -26,7 +26,7 @@ You probably need to configure Git and GCM Core to use a proxy. Please see detai ## About the project -### Q: How does project this 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)? +### 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)? Git Credential Manager for Windows (GCM Windows) is a .NET Framework-based Git credential helper which runs on Windows. Likewise the Git Credential Manager for Mac and Linux (Java GCM) is a Java-based Git credential helper that runs only on macOS and Linux. Although both of these projects aim to solve the same problem (providing seamless multi-factor HTTPS authentication with Git), they are based on different codebases and languages which is becoming hard to manage to ensure feature parity. @@ -39,7 +39,7 @@ No. Git Credential Manager for Windows (GCM Windows) will continue to be support ### 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 prefered on macOS and Linux to Java GCM. +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. SSH configuration instructions: diff --git a/docs/hostprovider.md b/docs/hostprovider.md index af68e5146..aca957c83 100644 --- a/docs/hostprovider.md +++ b/docs/hostprovider.md @@ -3,13 +3,18 @@ Property|Value -|- Author(s)|Matthew John Cheetham ([@mjcheetham](https://github.com/mjcheetham)) -Revision|1.0 -Last updated|2020-06-22 +Revision|1.1 +Last updated|2020-09-04 + +## Revision Summary + +- 1.0. Initial revision. +- 1.1. Replaced `GetCredentialKey` with `GetServiceName`. ## Abstract Git Credential Manger Core, the cross-platform and cross-host Git credential -helper, can be extended to support any Git hosting service allowing seemless +helper, can be extended to support any Git hosting service allowing seamless authentication to secured Git repositories by implementing and registering a "host provider". @@ -28,7 +33,7 @@ authentication to secured Git repositories by implementing and registering a - [2.4. Storing Credentials](#24-storing-credentials) - [2.5. Erasing Credentials](#25-erasing-credentials) - [2.6 `HostProvider` base class](#26-hostprovider-base-class) - - [2.6.1 `GetCredentialKey`](#261-getcredentialkey) + - [2.6.1 `GetServiceName`](#261-getservicename) - [2.6.2 `GenerateCredentialAsync`](#262-generatecredentialasync) - [2.7. External Metadata](#27-external-metadata) - [3. Helpers](#3-helpers) @@ -65,7 +70,7 @@ Mac/Linux" or "GCM Mac/Linux". OAuth2 [[RFC6749](https://tools.ietf.org/html/rfc6749)] "access tokens" are abbreviated to "ATs" and "refresh tokens" to "RTs". "Personal Access Tokens" are -abbreivated to "PATs". +abbreviated to "PATs". ## 2. Implementation @@ -109,7 +114,7 @@ register providers with by calling the `RegisterProvider` method. #### 2.1.2. Ordering The default host provider registry in GCM Core will call each host provider in -the order they were registered in, unless the user has overriden the provider +the order they were registered in, unless the user has overridden the provider selection process. There are no rules or restrictions on the ordering of host providers, except @@ -120,9 +125,9 @@ way. ### 2.2. Handling Requests The `IsSupported` method will be called on all registered host providers in-turn -on the invokation of a `get`, `store`, or `erase` request. The first host +on the invocation of a `get`, `store`, or `erase` request. The first host provider to return `true` will be called upon to handle the specific request. -If the user has overriden the host provider selection process, a specific host +If the user has overridden the host provider selection process, a specific host provider may be selected instead, and the `IsSupported` method will NOT be called. @@ -145,7 +150,7 @@ example "HTTP is not secure, please use HTTPS". ### 2.3. Retrieving Credentials The `GetCredentialAsync` method will be called when a `get` request is made. -The method MUST return an instance of an `ICredential` capable of fufilling the +The method MUST return an instance of an `ICredential` capable of fulfilling the specific access request. The argument passed to `GetCredentialAsync` contains properties indicating the required `protocol` and `host` for this request. The `username` and `path` properties are OPTIONAL, however if they are present, they @@ -158,7 +163,7 @@ The host provider MAY choose to check if a stored credential is still valid by inspecting any stored metadata associated with the value. A host provider MAY also choose to further validate a retrieved stored credential by making a web request. However, it is NOT RECOMMENDED to make any request that is known to be -slow or that typically produces inconclusive valudation results. +slow or that typically produces inconclusive validation results. If a provider chooses to make a validation web request and that request fails or is inconclusive, it SHOULD assume the credential is still valid and return it @@ -184,7 +189,7 @@ attempt first. Host providers are RECOMMENDED to attempt authentication mechanisms that do not require user interaction if possible. If there are multiple authentication mechanisms that could be equally considered "best" they MAY prompt the user -to make a selection. Host providers MAY wish to rememeber such a selection for +to make a selection. Host providers MAY wish to remember such a selection for future use, however they MUST make it clear how to clear this stored selection to the user. @@ -222,7 +227,7 @@ Host providers MAY store multiple credentials or tokens in the same request if it is required. One example where multiple credential storage is needed is with OAuth2 access tokens (AT) and refresh tokens (RT). Both the AT and RT SHOULD be stored in the same location using the credential store with complementary -credential keys. +credential service names. ### 2.5. Erasing Credentials @@ -249,33 +254,36 @@ provider implementors. This base class implements most required methods of the `IHostProvider` interface with common credential recall and storage behaviour. The `GetCredentialAsync`, `StoreCredentialAsync`, and `EraseCredentialAsync` -methods are implemented as `virtual` meaning they MAY be overriden by derived +methods are implemented as `virtual` meaning they MAY be overridden by derived classes to customise the behaviour of those operations. It is NOT RECOMMENDED to derive from the `HostProvider` base class if the implementor must override most of the methods as implemented - implementors SHOULD implement the `IHostProvider` interface directly instead. Implementors that choose to derive from this base class MUST implement all -abstract methods and properties. The two primary abstract methods to implement -are `GetCredentialKey` and `GenerateCredentialAsync`. +abstract methods and properties. The primary abstract method to implement +is `GenerateCredentialAsync`. -#### 2.6.1 `GetCredentialKey` +There is also an additional `virtual` method named `GetServiceName` that is used +by the default implementations of the `Get|Store|EraseCredentialAsync` methods +to locate and store credentials. -The `GetCredentialKey` method MUST return a string that forms a key for storing -credentials for this provider and request. The key returned MUST be stable - -i.e, it MUST return the same value given the same or equivalent input arguments. +#### 2.6.1 `GetServiceName` -This key is used by the `GetCredentialAsync` method to first check for any -existing credential stored in the credential store, returning it if found. +The `GetServiceName` virtual method, if overriden, MUST return a string that +identifies the service/provider for this request, and is used for storing +credentials. The value returned MUST be stable - i.e, it MUST return the same +value given the same or equivalent input arguments. -The key is also similarly used by the `StoreCredentialAsync` and -`EraseCredentialAsync` methods to store and erase, respectively, credentials -passed as arguments by Git. +By default this method returns the full remote URI, without a trailing slash, +including protocol/scheme, hostname, and path if present in the input arguments. +Any username in the input arguments is never included in the URI. #### 2.6.2 `GenerateCredentialAsync` The `GenerateCredentialAsync` method will be called if an existing credential -with a matching credential key is not found in the credential store. +with a matching service (from `GetServiceName`) and account is not found in the +credential store. This method MUST return a freshly created/generated credential and not any existing or stored one. It MAY use existing or stored ancillary data or tokens, @@ -300,7 +308,7 @@ features such as native APIs and native graphical user interfaces, in order to offer a better authentication experience. Host providers MUST function without the presence of a helper, even if that -function is to fail gracefully with a user friendly error message, including +function is to fail gracefully with a user-friendly error message, including a remedy to correct their installation. Host providers SHOULD always offer a terminal/TTY or text-based authentication mechanism alongside any graphical interface provided by a helper. @@ -312,7 +320,7 @@ etc. Communications between the main and helper processes MAY use any IPC mechanism available. It is RECOMMENDED implementors use standard input/output streams or -file descriptors to send and recieve data as this is consistent with how Git and +file descriptors to send and receive data as this is consistent with how Git and GCM Core communicate. UNIX sockets or Windows Named Pipes MAY also be used when an ongoing back-and-forth communication is required. diff --git a/docs/linuxcredstores.md b/docs/linuxcredstores.md new file mode 100644 index 000000000..92de5b498 --- /dev/null +++ b/docs/linuxcredstores.md @@ -0,0 +1,129 @@ +# Credential stores on Linux + +There are currently three options for storing credentials that Git Credential +Manager Core (GCM Core) manages on Linux platforms: + +1. [freedesktop.org Secret Service API](https://specifications.freedesktop.org/secret-service/) +2. GPG/[`pass`](https://www.passwordstore.org/) compatible files +3. Plaintext files + +By default, GCM Core comes unconfigured. You can select which credential store +to use by setting the [`GCM_CREDENTIAL_STORE`](environment.md#GCM_CREDENTIAL_STORE) +environment variable, or the [`credential.credentialStore`](configuration.md#credentialcredentialstore) +Git configuration setting. + +Some credential stores have limitations, or further configuration required +depending on your particular setup. + +## 1. [freedesktop.org Secret Service API](https://specifications.freedesktop.org/secret-service/) + +```shell +export GCM_CREDENTIAL_STORE=secretservice +# or +git config --global credential.credentialStore secretservice +``` + +**:warning: Requires a graphical user interface session.** + +This credential store uses the `libsecret` library to interact with the Secret +Service. It stores credentials securely in 'collections', which can be viewed by +tools such as `secret-tool` and `seahorse`. + +A graphical user interface is required in order to show a secure prompt to +request a secret collection be unlocked. + +## 2. GPG/[`pass`](https://www.passwordstore.org/) compatible files + +```shell +export GCM_CREDENTIAL_STORE=gpg +# or +git config --global credential.credentialStore gpg +``` + +**:warning: Requires `gpg`, `pass`, and a GPG key pair.** + +This credential store uses GPG to encrypt files containing credentials which are +stored in your file system. The file structure is compatible with the popular +[`pass`](https://www.passwordstore.org/) tool. By default files are stored in +`~/.password-store` but this can be configured using the `pass` environment +variable `PASSWORD_STORE_DIR`. + +Before you can use this credential store, it must be initialized by the `pass` +utility, which in-turn requires a valid GPG key pair. To initalize the store, +run: + +```shell +pass init +``` + +..where `` is the user ID of a GPG key pair on your system. To create a +new GPG key pair, run: + +```shell +gpg --gen-key +``` + +..and follow the prompts. + +### Headless/TTY-only sessions + +If you are using the `gpg` credential store in a headless/TTY-only environment, +you must ensure you have configured the GPG Agent (`gpg-agent`) with a suitable +pin-entry program for the terminal such as `pinentry-tty` or `pinentry-curses`. + +If you are connecting to your system via SSH, then the `SSH_TTY` variable should +automatically be set. GCM Core will pass the value of `SSH_TTY` to GPG/GPG Agent +as the TTY device to use for prompting for a passphrase. + +If you are not connecting via SSH, or otherwise do not have the `SSH_TTY` +environment variable set, you must set the `GPG_TTY` environment variable before +running GCM Core. The easiest way to do this is by adding the following to your +profile (`~/.bashrc`, `~/.profile` etc): + +```shell +export GPG_TTY=$(tty) +``` + +**Note:** Using `/dev/tty` does not appear to work here - you must use the real +TTY device path, as returned by the `tty` utility. + +## 3. Plaintext files + +```shell +export GCM_CREDENTIAL_STORE=plaintext +# or +git config --global credential.credentialStore plaintext +``` + +**:warning: This is not a secure method of credential storage!** + +This credential store saves credentials to plaintext files in your file system. +By default files are stored in `~/.gcm/store` but this can be configured using +the environment variable `GCM_PLAINTEXT_STORE_PATH` environment variable. + +If the directory does not exist is will be created. + +--- + +

+ +:warning: **WARNING** :warning: + +**This storage mechanism is NOT secure!** + +**Secrets and credentials are stored in plaintext files _without any security_!
+Git Credential Manager Core takes no liability for the safety of these +credentials.** + +It is **HIGHLY RECOMMENDED** to always use one of the other credential store +options above. This option is only provided for compatibility and use in +environments where no other secure option is available. + +If you chose to use this credential store, it is recommended you set the +permissions on this directory such that no other users or applications can +access files within. If possible, use a path that exists on an external volume +that you take with you and use full-disk encryption. + +

+ +--- diff --git a/docs/netconfig.md b/docs/netconfig.md index c430e9999..b2e969890 100644 --- a/docs/netconfig.md +++ b/docs/netconfig.md @@ -6,7 +6,7 @@ Git Credential Manager Core's network and HTTP(S) behavior can be configured in If your computer sits behind a network firewall that requires the use of a proxy server to reach repository remotes or the wider Internet, there are various methods for configuring GCM to use a proxy. -The simplist way to configure a proxy for _all_ HTTP(S) remotes is to [use the standard Git HTTP(S) proxy setting `http.proxy`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy). +The simplest way to configure a proxy for _all_ HTTP(S) remotes is to [use the standard Git HTTP(S) proxy setting `http.proxy`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy). For example to configure a proxy for all remotes for the current user: @@ -57,7 +57,7 @@ GCM Core supports other ways of configuring a proxy for convenience and compatib ## TLS Verification -If you are using self-signed TLS (SSL) certificates with a self-hosted host provider such as GitHub Enteprise Server or Azure DevOps Server (previously TFS), you may see the following error message when attempting to connect using Git and/or GCM: +If you are using self-signed TLS (SSL) certificates with a self-hosted host provider such as GitHub Enterprise Server or Azure DevOps Server (previously TFS), you may see the following error message when attempting to connect using Git and/or GCM: ```shell $ git clone https://ghe.example.com/john.doe/myrepo @@ -66,7 +66,7 @@ fatal: The remote certificate is invalid according to the validation procedure. The **recommended and safest option** is to acquire a TLS certificate signed by a public trusted certificate authority (CA). There are multiple public CAs; here is a non-exhaustive list to consider: [Let's Encrypt](https://letsencrypt.org/), [Comodo](https://www.comodoca.com/), [Digicert](https://www.digicert.com/), [GoDaddy](https://www.godaddy.com/web-security/ssl-certificate), [GlobalSign](https://www.globalsign.com/en/ssl/). -If it is not possible to **obtain a TLS certifiate from a trusted 3rd party** then you should try to add the _specific_ self-signed certificate or one of the CA certificates in the verification chain to your operating system's trusted certificate store ([macOS](https://support.apple.com/en-gb/guide/keychain-access/kyca2431/mac), [Windows](https://blogs.technet.microsoft.com/sbs/2008/05/08/installing-a-self-signed-certificate-as-a-trusted-root-ca-in-windows-vista/)). +If it is not possible to **obtain a TLS certificate from a trusted 3rd party** then you should try to add the _specific_ self-signed certificate or one of the CA certificates in the verification chain to your operating system's trusted certificate store ([macOS](https://support.apple.com/en-gb/guide/keychain-access/kyca2431/mac), [Windows](https://blogs.technet.microsoft.com/sbs/2008/05/08/installing-a-self-signed-certificate-as-a-trusted-root-ca-in-windows-vista/)). If you are _unable_ to either **obtain a trusted certificate**, or trust the self-signed certificate you can disable certificate verification in Git and GCM. @@ -75,11 +75,11 @@ If you are _unable_ to either **obtain a trusted certificate**, or trust the sel Disabling verification of TLS (SSL) certificates removes protection against a [man-in-the-middle (MITM) attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack). -Only disable certificate verification if you are sure you need to, are aware of all of the risks, and are unable to trust specific self-signed certificates (as described above). +Only disable certificate verification if you are sure you need to, are aware of all the risks, and are unable to trust specific self-signed certificates (as described above). --- -The [environment variable `GIT_SSL_NO_VERIFY`](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_networking) and [Git configuration option `http.sslVerify`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpsslVerify) can be used to control TLS (SSL) certifcate verification. +The [environment variable `GIT_SSL_NO_VERIFY`](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_networking) and [Git configuration option `http.sslVerify`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpsslVerify) can be used to control TLS (SSL) certificate verification. To disable verification for a specific remote (for example ): diff --git a/src/linux/Directory.Build.props b/src/linux/Directory.Build.props new file mode 100644 index 000000000..190291cfd --- /dev/null +++ b/src/linux/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + + $(RepoOutPath)linux\ + $(PlatformOutPath)$(MSBuildProjectName)\ + $(ProjectOutPath)bin\ + $(ProjectOutPath)obj\ + + diff --git a/src/linux/Packaging.Linux/Packaging.Linux.csproj b/src/linux/Packaging.Linux/Packaging.Linux.csproj new file mode 100644 index 000000000..3eeb3f45f --- /dev/null +++ b/src/linux/Packaging.Linux/Packaging.Linux.csproj @@ -0,0 +1,32 @@ + + + + + + netcoreapp3.1 + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/linux/Packaging.Linux/build.sh b/src/linux/Packaging.Linux/build.sh new file mode 100755 index 000000000..9f2b857b1 --- /dev/null +++ b/src/linux/Packaging.Linux/build.sh @@ -0,0 +1,160 @@ +#!/bin/bash +die () { + echo "$*" >&2 + exit 1 +} + +make_absolute () { + case "$1" in + /*) + echo "$1" + ;; + *) + echo "$PWD/$1" + ;; + esac +} + +##################################################################### +# Building +##################################################################### +echo "Building Packaging.Linux..." + +# Parse script arguments +for i in "$@" +do +case "$i" in + --configuration=*) + CONFIGURATION="${i#*=}" + shift # past argument=value + ;; + --version=*) + VERSION="${i#*=}" + shift # past argument=value + ;; + *) + # unknown option + ;; +esac +done + +# Directories +THISDIR="$( cd "$(dirname "$0")" ; pwd -P )" +ROOT="$( cd "$THISDIR"/../../.. ; pwd -P )" +SRC="$ROOT/src" +OUT="$ROOT/out" +GCM_SRC="$SRC/shared/Git-Credential-Manager" +PROJ_OUT="$OUT/linux/Packaging.Linux" + +# Build parameters +FRAMEWORK=netcoreapp3.1 +RUNTIME=linux-x64 + +# Perform pre-execution checks +CONFIGURATION="${CONFIGURATION:=Debug}" +if [ -z "$VERSION" ]; then + die "--version was not set" +fi + +ARCH="`dpkg-architecture -q DEB_HOST_ARCH`" +if test -z "$ARCH"; then + die "Could not determine host architecture!" +fi + +# Outputs +PAYLOAD="$PROJ_OUT/payload/$CONFIGURATION" +SYMBOLOUT="$PROJ_OUT/payload.sym/$CONFIGURATION" + +TAROUT="$PROJ_OUT/tar/$CONFIGURATION" +TARBALL="$TAROUT/gcmcore-linux_$ARCH.$VERSION.tar.gz" +SYMTARBALL="$TAROUT/symbols-linux_$ARCH.$VERSION.tar.gz" + +DEBOUT="$PROJ_OUT/deb/$CONFIGURATION" +DEBROOT="$DEBOUT/root" +DEBPKG="$DEBOUT/gcmcore-linux_$ARCH.$VERSION.deb" + +# Cleanup payload directory +if [ -d "$PAYLOAD" ]; then + echo "Cleaning existing payload directory '$PAYLOAD'..." + rm -rf "$PAYLOAD" +fi + +# Cleanup symbol directory +if [ -d "$SYMBOLOUT" ]; then + echo "Cleaning existing symbols directory '$SYMBOLOUT'..." + rm -rf "$SYMBOLOUT" +fi + +# Ensure directories exists +mkdir -p "$PAYLOAD" "$SYMBOLOUT" "$DEBROOT" + +# Publish core application executables +echo "Publishing core application..." +dotnet publish "$GCM_SRC" \ + --configuration="$CONFIGURATION" \ + --framework="$FRAMEWORK" \ + --runtime="$RUNTIME" \ + --self-contained=true \ + "/p:PublishSingleFile=True" \ + --output="$(make_absolute "$PAYLOAD")" || exit 1 + +# Collect symbols +echo "Collecting managed symbols..." +mv "$PAYLOAD"/*.pdb "$SYMBOLOUT" || exit 1 + +echo "Build complete." + +##################################################################### +# PACKING +##################################################################### +echo "Packing Packaging.Linux..." +# Cleanup any old archive files +if [ -e "$TAROUT" ]; then + echo "Deleteing old archive '$TAROUT'..." + rm "$TAROUT" +fi + +# Ensure the parent directory for the archive exists +mkdir -p "$TAROUT" || exit 1 + +# Set full read, write, execute permissions for owner and just read and execute permissions for group and other +echo "Setting file permissions..." +/bin/chmod -R 755 "$PAYLOAD" || exit 1 + +# Build binaries tarball +echo "Building binaries tarball..." +pushd "$PAYLOAD" +tar -czvf "$TARBALL" * || exit 1 +popd + +# Build symbols tarball +echo "Building symbols tarball..." +pushd "$SYMBOLOUT" +tar -czvf "$SYMTARBALL" * || exit 1 +popd + +# Build .deb +INSTALL_TO="$DEBROOT/usr/bin/" +mkdir -p "$DEBROOT/DEBIAN" "$INSTALL_TO" || exit 1 + +# make the debian control file +cat >"$DEBROOT/DEBIAN/control" < +Description: Cross Platform Git-Credential-Manager-Core command line utility. + Linux build of the GCM-Core project to support auth with a number of + git hosting providers including GitHub, BitBucket, and Azure DevOps. + Hosted at https://github.com/microsoft/Git-Credential-Manager-Core +EOF + +# Copy single binary to target installation location +cp "$PAYLOAD/git-credential-manager-core" "$INSTALL_TO" || exit 1 + +dpkg-deb --build "$DEBROOT" "$DEBPKG" || exit 1 + +echo "Pack complete." diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index c54934490..fb4da154a 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Net; -using System.Text; using System.Threading.Tasks; using Microsoft.Git.CredentialManager; using Microsoft.Git.CredentialManager.Authentication.OAuth; @@ -40,50 +39,57 @@ public BitbucketHostProvider(ICommandContext context, IBitbucketAuthentication b public bool IsSupported(InputArguments input) { + if (input is null) + { + return false; + } + + // Split port number and hostname from host input argument + input.TryGetHostAndPort(out string hostName, out _); + // We do not support unencrypted HTTP communications to Bitbucket, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `GetCredentialAsync`. - return input != null && - (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || + return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && - input.Host.EndsWith(BitbucketConstants.BitbucketBaseUrlHost, StringComparison.OrdinalIgnoreCase); + hostName.EndsWith(BitbucketConstants.BitbucketBaseUrlHost, StringComparison.OrdinalIgnoreCase); + } public async Task GetCredentialAsync(InputArguments input) { - // Compute the target URI - Uri targetUri = GetTargetUri(input); + // Compute the remote URI + Uri targetUri = input.GetRemoteUri(); + + bool isBitbucketServer = IsBitbucketServer(input); // We should not allow unencrypted communication and should inform the user if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") - && !IsBitbucketServer(targetUri)) + && !isBitbucketServer) { throw new Exception("Unencrypted HTTP is not supported for Bitbucket.org. Ensure the repository remote URL is using HTTPS."); } - // Try and get the username specified in the remote URL if any - string targetUriUser = targetUri.GetUserName(); - // Check for presence of refresh_token entry in credential store - string refreshKey = GetRefreshTokenKey(input); - _context.Trace.WriteLine($"Checking for refresh token with key '{refreshKey}'..."); - ICredential refreshToken = _context.CredentialStore.Get(refreshKey); + string refreshTokenService = GetRefreshTokenServiceName(input); + _context.Trace.WriteLine("Checking for refresh token..."); + ICredential refreshToken = _context.CredentialStore.Get(refreshTokenService, input.UserName); if (refreshToken is null) { // There is no refresh token either because this is a non-2FA enabled account (where OAuth is not // required), or because we previously erased the RT. // Check for the presence of a credential in the store - string credentialKey = GetCredentialKey(input); - _context.Trace.WriteLine($"Checking for credentials with key '{credentialKey}'..."); - ICredential credential = _context.CredentialStore.Get(credentialKey); + string credentialService = GetServiceName(input); + _context.Trace.WriteLine("Checking for credentials..."); + ICredential credential = _context.CredentialStore.Get(credentialService, input.UserName); if (credential is null) { // We don't have any credentials to use at all! Start with the assumption of no 2FA requirement // and capture username and password via an interactive prompt. - credential = await _bitbucketAuth.GetBasicCredentialsAsync(targetUri, targetUriUser); + credential = await _bitbucketAuth.GetBasicCredentialsAsync(targetUri, input.UserName); if (credential is null) { throw new Exception("User cancelled authentication prompt."); @@ -94,7 +100,9 @@ public async Task GetCredentialAsync(InputArguments input) // or we have a freshly captured user/pass. Regardless, we must check if these credentials // pass and two-factor requirement on the account. _context.Trace.WriteLine("Checking if two-factor requirements for stored credentials..."); - bool requires2Fa = await RequiresTwoFactorAuthenticationAsync(credential, targetUri); + + // BBS does not support 2FA out of the box so neither does GCM + bool requires2Fa = !isBitbucketServer && await RequiresTwoFactorAuthenticationAsync(credential); if (!requires2Fa) { _context.Trace.WriteLine("Two-factor requirement passed with stored credentials"); @@ -134,9 +142,8 @@ public async Task GetCredentialAsync(InputArguments input) _context.Trace.WriteLine($"Username for refreshed OAuth credential is '{refreshUserName}'"); // Store the refreshed RT - _context.Trace.WriteLine($"Storing new refresh token with key '{refreshKey}'..."); - _context.CredentialStore.AddOrUpdate(refreshKey, - new GitCredential(refreshUserName, refreshResult.RefreshToken)); + _context.Trace.WriteLine("Storing new refresh token..."); + _context.CredentialStore.AddOrUpdate(refreshTokenService, input.UserName, refreshResult.RefreshToken); // Return new access token return new GitCredential(refreshUserName, refreshResult.AccessToken); @@ -164,8 +171,8 @@ public async Task GetCredentialAsync(InputArguments input) _context.Trace.WriteLine($"Username for OAuth credential is '{newUserName}'"); // Store the new RT - _context.Trace.WriteLine($"Storing new refresh token with key '{refreshKey}'..."); - _context.CredentialStore.AddOrUpdate(refreshKey, new GitCredential(newUserName, oauthResult.RefreshToken)); + _context.Trace.WriteLine("Storing new refresh token..."); + _context.CredentialStore.AddOrUpdate(refreshTokenService, newUserName, oauthResult.RefreshToken); _context.Trace.WriteLine("Refresh token was successfully stored."); // Return the new AT as the credential @@ -177,26 +184,12 @@ public Task StoreCredentialAsync(InputArguments input) // It doesn't matter if this is an OAuth access token, or the literal username & password // because we store them the same way, against the same credential key in the store. // The OAuth refresh token is already stored on the 'get' request. - string credentialKey = GetCredentialKey(input); - ICredential credential = new GitCredential(input.UserName, input.Password); + string service = GetServiceName(input); - _context.Trace.WriteLine($"Storing credential with key '{credentialKey}'..."); - _context.CredentialStore.AddOrUpdate(credentialKey, credential); + _context.Trace.WriteLine("Storing credential..."); + _context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); _context.Trace.WriteLine("Credential was successfully stored."); - Uri targetUri = GetTargetUri(input); - if (IsBitbucketServer(targetUri)) - { - // BBS doesn't usually include the username in the urls which means they aren't included in the GET call, - // which means if we store only with the username the credentials are never found again ... - // This does have the potential to overwrite itself for different BbS accounts, - // but typically BbS doesn't encourage multiple user accounts - string bbsCredentialKey = GetBitbucketServerCredentialKey(input); - _context.Trace.WriteLine($"Storing Bitbucket Server credential with key '{bbsCredentialKey}'..."); - _context.CredentialStore.AddOrUpdate(bbsCredentialKey, credential); - _context.Trace.WriteLine("Bitbucket Server Credential was successfully stored."); - } - return Task.CompletedTask; } @@ -205,9 +198,10 @@ public Task EraseCredentialAsync(InputArguments input) // Erase the stored credential (which may be either the literal username & password, or // the OAuth access token). We don't need to erase the OAuth refresh token because on the // next 'get' request, if the RT is bad we will erase and reacquire a new one at that point. - string credentialKey = GetCredentialKey(input); - _context.Trace.WriteLine($"Erasing credential with key '{credentialKey}'..."); - if (_context.CredentialStore.Remove(credentialKey)) + string service = GetServiceName(input); + + _context.Trace.WriteLine("Erasing credential..."); + if (_context.CredentialStore.Remove(service, input.UserName)) { _context.Trace.WriteLine("Credential was successfully erased."); } @@ -234,15 +228,10 @@ private async Task ResolveOAuthUserNameAsync(string accessToken) throw new Exception($"Failed to resolve username. HTTP: {result.StatusCode}"); } - private async Task RequiresTwoFactorAuthenticationAsync(ICredential credentials, Uri targetUri) + private async Task RequiresTwoFactorAuthenticationAsync(ICredential credentials) { - if (IsBitbucketServer(targetUri)) - { - // BBS does not support 2FA out of the box so neither does GCM - return false; - } - - RestApiResult result = await _bitbucketApi.GetUserInformationAsync(credentials.UserName, credentials.Password, false); + RestApiResult result = await _bitbucketApi.GetUserInformationAsync( + credentials.Account, credentials.Password, false); switch (result.StatusCode) { // 2FA may not be required @@ -262,91 +251,26 @@ private async Task RequiresTwoFactorAuthenticationAsync(ICredential creden } } - private string GetCredentialKey(InputArguments input) - { - // The credential (user/pass or an OAuth access token) key is the full target URI. - // If the full path is included (credential.useHttpPath = true) then respect that. - string url = GetTargetUri(input).AbsoluteUri; - - // Trim trailing slash - if (url.EndsWith("/")) - { - url = url.Substring(0, url.Length - 1); - } - - return $"git:{url}"; - } - - private string GetBitbucketServerCredentialKey(InputArguments input) + private static string GetServiceName(InputArguments input) { - // The credential (user/pass or an OAuth access token) key is the full target URI. - // If the full path is included (credential.useHttpPath = true) then respect that. - string url = GetBitbucketServerTargetUri(input).AbsoluteUri; - - // Trim trailing slash - if (url.EndsWith("/")) - { - url = url.Substring(0, url.Length - 1); - } - - return $"git:{url}"; + return input.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/'); } - private string GetRefreshTokenKey(InputArguments input) + private static string GetRefreshTokenServiceName(InputArguments input) { - Uri targetUri = GetTargetUri(input); + Uri baseUri = input.GetRemoteUri(includeUser: false); // The refresh token key never includes the path component. - // Starting from the full target URI, build the following: - // - // {scheme}://[{userinfo}@]{authority}/refresh_token - // - - var url = new StringBuilder(); - - url.Append(targetUri.Scheme) - .Append(Uri.SchemeDelimiter); - - if (!string.IsNullOrWhiteSpace(targetUri.UserInfo)) - { - url.Append(targetUri.UserInfo) - .Append('@'); - } - - url.Append(targetUri.Authority) - .Append("/refresh_token"); - - return $"git:{url}"; - } - - private static Uri GetTargetUri(InputArguments input) - { - Uri uri = new UriBuilder - { - Scheme = input.Protocol, - Host = input.Host, - Path = input.Path, - UserName = input.UserName - }.Uri; - - return uri; - } - - private static Uri GetBitbucketServerTargetUri(InputArguments input) - { - Uri uri = new UriBuilder - { - Scheme = input.Protocol, - Host = input.Host, - Path = input.Path - }.Uri; + // Instead we use the path component to specify this is the "refresh_token". + Uri uri = new UriBuilder(baseUri) {Path = "/refresh_token"}.Uri; - return uri; + return uri.AbsoluteUri.TrimEnd('/'); } - private bool IsBitbucketServer(Uri targetUri) + private static bool IsBitbucketServer(InputArguments input) { - return !targetUri.Host.Equals(BitbucketConstants.BitbucketBaseUrlHost); + input.TryGetHostAndPort(out string hostName, out _); + return !hostName.Equals(BitbucketConstants.BitbucketBaseUrlHost); } #endregion diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj index e3801a280..5d3df4e8e 100644 --- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj +++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj @@ -4,7 +4,7 @@ Exe netcoreapp3.1 net461;netcoreapp3.1 - win-x86;osx-x64 + win-x86;osx-x64;linux-x64 x86 git-credential-manager-core Microsoft.Git.CredentialManager diff --git a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs index 71a93c990..3bfb292e5 100644 --- a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs +++ b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs @@ -112,9 +112,9 @@ public void GitHubHostProvider_IsSupported_NonGitHub_ReturnsFalse() } [Fact] - public void GitHubHostProvider_GetCredentialKey_GitHubHost_ReturnsCorrectKey() + public void GitHubHostProvider_GetCredentialServiceUrl_GitHubHost_ReturnsCorrectKey() { - const string expectedKey = "git:https://github.com"; + const string expectedService = "https://github.com"; var input = new InputArguments(new Dictionary { ["protocol"] = "https", @@ -122,14 +122,14 @@ public void GitHubHostProvider_GetCredentialKey_GitHubHost_ReturnsCorrectKey() }); var provider = new GitHubHostProvider(new TestCommandContext()); - string actualKey = provider.GetCredentialKey(input); - Assert.Equal(expectedKey, actualKey); + string actualService = provider.GetServiceName(input); + Assert.Equal(expectedService, actualService); } [Fact] - public void GitHubHostProvider_GetCredentialKey_GistHost_ReturnsCorrectKey() + public void GitHubHostProvider_GetCredentialServiceUrl_GistHost_ReturnsCorrectKey() { - const string expectedKey = "git:https://github.com"; + const string expectedService = "https://github.com"; var input = new InputArguments(new Dictionary { ["protocol"] = "https", @@ -137,8 +137,8 @@ public void GitHubHostProvider_GetCredentialKey_GistHost_ReturnsCorrectKey() }); var provider = new GitHubHostProvider(new TestCommandContext()); - string actualKey = provider.GetCredentialKey(input); - Assert.Equal(expectedKey, actualKey); + string actualService = provider.GetServiceName(input); + Assert.Equal(expectedService, actualService); } [Fact] @@ -175,7 +175,7 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes_Override_Re public async Task GitHubHostProvider_GetSupportedAuthenticationModes_OverrideInvalid_ReturnsDetectedValue() { var targetUri = new Uri("https://github.com"); - var expectedModes = GitHubConstants.DotDomAuthenticationModes; + var expectedModes = GitHubConstants.DotComAuthenticationModes; var context = new TestCommandContext { @@ -205,7 +205,7 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes_OverrideInv public async Task GitHubHostProvider_GetSupportedAuthenticationModes_OverrideNone_ReturnsDetectedValue() { var targetUri = new Uri("https://github.com"); - var expectedModes = GitHubConstants.DotDomAuthenticationModes; + var expectedModes = GitHubConstants.DotComAuthenticationModes; var context = new TestCommandContext { @@ -235,7 +235,7 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes_OverrideNon public async Task GitHubHostProvider_GetSupportedAuthenticationModes_GitHubDotCom_ReturnsDotComModes() { var targetUri = new Uri("https://github.com"); - var expectedModes = GitHubConstants.DotDomAuthenticationModes; + var expectedModes = GitHubConstants.DotComAuthenticationModes; var provider = new GitHubHostProvider(new TestCommandContext()); @@ -379,26 +379,29 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_OAuth_ReturnsCreden GitHubConstants.OAuthScopes.Workflow, }; + var expectedUserName = "john.doe"; var tokenValue = "OAUTH-TOKEN"; var response = new OAuth2TokenResult(tokenValue, "bearer"); var context = new TestCommandContext(); var ghAuthMock = new Mock(MockBehavior.Strict); - ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, It.IsAny())) + ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny())) .ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.OAuth)); ghAuthMock.Setup(x => x.GetOAuthTokenAsync(expectedTargetUri, It.IsAny>())) .ReturnsAsync(response); var ghApiMock = new Mock(MockBehavior.Strict); + ghApiMock.Setup(x => x.GetUserInfoAsync(expectedTargetUri, tokenValue)) + .ReturnsAsync(new GitHubUserInfo{Login = expectedUserName}); var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(Constants.OAuthTokenUserName, credential.UserName); + Assert.Equal(expectedUserName, credential.Account); Assert.Equal(tokenValue, credential.Password); ghAuthMock.Verify( @@ -426,25 +429,26 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Basic_1FAOnly_Retur }; var patValue = "PERSONAL-ACCESS-TOKEN"; - var pat = new GitCredential(Constants.PersonalAccessTokenUserName, patValue); - var response = new AuthenticationResult(GitHubAuthenticationResultType.Success, pat); + var response = new AuthenticationResult(GitHubAuthenticationResultType.Success, patValue); var context = new TestCommandContext(); var ghAuthMock = new Mock(MockBehavior.Strict); - ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, It.IsAny())) + ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny())) .ReturnsAsync(new AuthenticationPromptResult(new GitCredential(expectedUserName, expectedPassword))); var ghApiMock = new Mock(MockBehavior.Strict); ghApiMock.Setup(x => x.CreatePersonalAccessTokenAsync(expectedTargetUri, expectedUserName, expectedPassword, null, It.IsAny>())) .ReturnsAsync(response); + ghApiMock.Setup(x => x.GetUserInfoAsync(expectedTargetUri, patValue)) + .ReturnsAsync(new GitHubUserInfo{Login = expectedUserName}); var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(Constants.PersonalAccessTokenUserName, credential.UserName); + Assert.Equal(expectedUserName, credential.Account); Assert.Equal(patValue, credential.Password); ghApiMock.Verify( @@ -473,14 +477,13 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Basic_2FARequired_R }; var patValue = "PERSONAL-ACCESS-TOKEN"; - var pat = new GitCredential(Constants.PersonalAccessTokenUserName, patValue); var response1 = new AuthenticationResult(GitHubAuthenticationResultType.TwoFactorApp); - var response2 = new AuthenticationResult(GitHubAuthenticationResultType.Success, pat); + var response2 = new AuthenticationResult(GitHubAuthenticationResultType.Success, patValue); var context = new TestCommandContext(); var ghAuthMock = new Mock(MockBehavior.Strict); - ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, It.IsAny())) + ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny())) .ReturnsAsync(new AuthenticationPromptResult(new GitCredential(expectedUserName, expectedPassword))); ghAuthMock.Setup(x => x.GetTwoFactorCodeAsync(expectedTargetUri, false)) .ReturnsAsync(expectedAuthCode); @@ -490,13 +493,15 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Basic_2FARequired_R .ReturnsAsync(response1); ghApiMock.Setup(x => x.CreatePersonalAccessTokenAsync(expectedTargetUri, expectedUserName, expectedPassword, expectedAuthCode, It.IsAny>())) .ReturnsAsync(response2); + ghApiMock.Setup(x => x.GetUserInfoAsync(expectedTargetUri, patValue)) + .ReturnsAsync(new GitHubUserInfo{Login = expectedUserName}); var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(Constants.PersonalAccessTokenUserName, credential.UserName); + Assert.Equal(expectedUserName, credential.Account); Assert.Equal(patValue, credential.Password); ghApiMock.Verify( diff --git a/src/shared/GitHub.Tests/GitHubRestApiTests.cs b/src/shared/GitHub.Tests/GitHubRestApiTests.cs index 4f15de893..57bb4b39c 100644 --- a/src/shared/GitHub.Tests/GitHubRestApiTests.cs +++ b/src/shared/GitHub.Tests/GitHubRestApiTests.cs @@ -1,14 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; -using Microsoft.Git.CredentialManager; using Microsoft.Git.CredentialManager.Tests.Objects; using Xunit; @@ -90,7 +88,7 @@ public async Task GitHubRestApi_AcquireTokenAsync_ValidRequestOK_ReturnsToken() Assert.Equal(GitHubAuthenticationResultType.Success, authResult.Type); Assert.NotNull(authResult.Token); - Assert.Equal(expectedTokenValue, authResult.Token.Password); + Assert.Equal(expectedTokenValue, authResult.Token); } [Fact] @@ -167,7 +165,7 @@ public async Task GitHubRestApi_AcquireTokenAsync_ValidRequestCreated_ReturnsTok Assert.Equal(GitHubAuthenticationResultType.Success, authResult.Type); Assert.NotNull(authResult.Token); - Assert.Equal(expectedTokenValue, authResult.Token.Password); + Assert.Equal(expectedTokenValue, authResult.Token); } [Fact] @@ -267,7 +265,7 @@ public async Task GitHubRestApi_AcquireTokenAsync_ValidOAuthToken_ReturnsOAuthTo Assert.Equal(GitHubAuthenticationResultType.Success, authResult.Type); Assert.NotNull(authResult.Token); - Assert.Equal(testOAuthToken, authResult.Token.Password); + Assert.Equal(testOAuthToken, authResult.Token); } [Fact] diff --git a/src/shared/GitHub/AuthenticationResult.cs b/src/shared/GitHub/AuthenticationResult.cs index cc19a706f..6797d6cdc 100644 --- a/src/shared/GitHub/AuthenticationResult.cs +++ b/src/shared/GitHub/AuthenticationResult.cs @@ -12,7 +12,7 @@ public AuthenticationResult(GitHubAuthenticationResultType type) Token = null; } - public AuthenticationResult(GitHubAuthenticationResultType type, GitCredential token) + public AuthenticationResult(GitHubAuthenticationResultType type, string token) { Type = type; Token = token; @@ -20,7 +20,7 @@ public AuthenticationResult(GitHubAuthenticationResultType type, GitCredential t public GitHubAuthenticationResultType Type { get; } - public GitCredential Token { get; } + public string Token { get; } } public enum GitHubAuthenticationResultType diff --git a/src/shared/GitHub/GitHubAuthentication.cs b/src/shared/GitHub/GitHubAuthentication.cs index d7be41dae..36566265e 100644 --- a/src/shared/GitHub/GitHubAuthentication.cs +++ b/src/shared/GitHub/GitHubAuthentication.cs @@ -14,7 +14,7 @@ namespace GitHub { public interface IGitHubAuthentication : IDisposable { - Task GetAuthenticationAsync(Uri targetUri, AuthenticationModes modes); + Task GetAuthenticationAsync(Uri targetUri, string userName, AuthenticationModes modes); Task GetTwoFactorCodeAsync(Uri targetUri, bool isSms); @@ -57,7 +57,7 @@ public class GitHubAuthentication : AuthenticationBase, IGitHubAuthentication public GitHubAuthentication(ICommandContext context) : base(context) {} - public async Task GetAuthenticationAsync(Uri targetUri, AuthenticationModes modes) + public async Task GetAuthenticationAsync(Uri targetUri, string userName, AuthenticationModes modes) { ThrowIfUserInteractionDisabled(); @@ -71,7 +71,8 @@ public async Task GetAuthenticationAsync(Uri targetU var promptArgs = new StringBuilder("prompt"); 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.ToString()); + if (!GitHubHostProvider.IsGitHubDotCom(targetUri)) promptArgs.AppendFormat(" --enterprise-url {0}", targetUri); + if (!string.IsNullOrWhiteSpace(userName)) promptArgs.AppendFormat("--username {0}", userName); IDictionary resultDict = await InvokeHelperAsync(helperPath, promptArgs.ToString(), null); @@ -86,7 +87,7 @@ public async Task GetAuthenticationAsync(Uri targetU return new AuthenticationPromptResult(AuthenticationModes.OAuth); case "basic": - if (!resultDict.TryGetValue("username", out string userName)) + if (!resultDict.TryGetValue("username", out userName)) { throw new Exception("Missing 'username' in response"); } @@ -112,8 +113,8 @@ public async Task GetAuthenticationAsync(Uri targetU var menuTitle = $"Select an authentication method for '{targetUri}'"; var menu = new TerminalMenu(Context.Terminal, menuTitle) { - new TerminalMenuItem(1, "Web browser"), - new TerminalMenuItem(2, "Username/password", true) + new TerminalMenuItem(1, "Web browser", isDefault: true), + new TerminalMenuItem(2, "Username/password") }; int option = menu.Show(); @@ -125,7 +126,16 @@ public async Task GetAuthenticationAsync(Uri targetU case AuthenticationModes.Basic: Context.Terminal.WriteLine("Enter GitHub credentials for '{0}'...", targetUri); - string userName = Context.Terminal.Prompt("Username"); + + if (string.IsNullOrWhiteSpace(userName)) + { + userName = Context.Terminal.Prompt("Username"); + } + else + { + Context.Terminal.WriteLine("Username: {0}", userName); + } + string password = Context.Terminal.PromptSecret("Password"); return new AuthenticationPromptResult(new GitCredential(userName, password)); diff --git a/src/shared/GitHub/GitHubConstants.cs b/src/shared/GitHub/GitHubConstants.cs index 187284e41..4b348130e 100644 --- a/src/shared/GitHub/GitHubConstants.cs +++ b/src/shared/GitHub/GitHubConstants.cs @@ -33,8 +33,7 @@ public static class GitHubConstants /// /// Supported authentication modes for GitHub.com. /// - // TODO: remove Basic once the GCM OAuth app is whitelisted and does not require installation in every organization - public const AuthenticationModes DotDomAuthenticationModes = AuthenticationModes.Basic | AuthenticationModes.OAuth; + public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.OAuth; public static class TokenScopes { diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index f155c1323..f08147629 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -47,27 +47,32 @@ public GitHubHostProvider(ICommandContext context, IGitHubRestApi gitHubApi, IGi public override bool IsSupported(InputArguments input) { + if (input is null) + { + return false; + } + + // Split port number and hostname from host input argument + input.TryGetHostAndPort(out string hostName, out _); + // We do not support unencrypted HTTP communications to GitHub, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. - return input != null && - (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || + return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && - (StringComparer.OrdinalIgnoreCase.Equals(input.Host, GitHubConstants.GitHubBaseUrlHost) || - StringComparer.OrdinalIgnoreCase.Equals(input.Host, GitHubConstants.GistBaseUrlHost)); + (StringComparer.OrdinalIgnoreCase.Equals(hostName, GitHubConstants.GitHubBaseUrlHost) || + StringComparer.OrdinalIgnoreCase.Equals(hostName, GitHubConstants.GistBaseUrlHost)); } - public override string GetCredentialKey(InputArguments input) + public override string GetServiceName(InputArguments input) { - string url = GetTargetUri(input).AbsoluteUri; + var baseUri = new Uri(base.GetServiceName(input)); - // Trim trailing slash - if (url.EndsWith("/")) - { - url = url.Substring(0, url.Length - 1); - } + // Normalise the URI + string url = NormalizeUri(baseUri).AbsoluteUri; - return $"git:{url}"; + // Trim trailing slash + return url.TrimEnd('/'); } public override async Task GenerateCredentialAsync(InputArguments input) @@ -80,16 +85,19 @@ public override async Task GenerateCredentialAsync(InputArguments i throw new Exception("Unencrypted HTTP is not supported for GitHub. Ensure the repository remote URL is using HTTPS."); } - Uri targetUri = GetTargetUri(input); + Uri remoteUri = input.GetRemoteUri(); + + string service = GetServiceName(input); - AuthenticationModes authModes = await GetSupportedAuthenticationModesAsync(targetUri); + AuthenticationModes authModes = await GetSupportedAuthenticationModesAsync(remoteUri); - AuthenticationPromptResult promptResult = await _gitHubAuth.GetAuthenticationAsync(targetUri, authModes); + AuthenticationPromptResult promptResult = await _gitHubAuth.GetAuthenticationAsync(remoteUri, input.UserName, authModes); switch (promptResult.AuthenticationMode) { case AuthenticationModes.Basic: - ICredential patCredential = await GeneratePersonalAccessTokenAsync(targetUri, promptResult.BasicCredential); + GitCredential patCredential = await GeneratePersonalAccessTokenAsync(remoteUri, promptResult.BasicCredential); + // HACK: Store the PAT immediately in case this PAT is not valid for SSO. // We don't know if this PAT is valid for SAML SSO and if it's not Git will fail // with a 403 and call neither 'store' or 'erase'. The user is expected to fiddle with @@ -97,38 +105,42 @@ public override async Task GenerateCredentialAsync(InputArguments i // We must store the PAT now so they can resume/repeat the operation with the same, // now SSO authorized, PAT. // See: https://github.com/microsoft/Git-Credential-Manager-Core/issues/133 - Context.CredentialStore.AddOrUpdate(GetCredentialKey(input), patCredential); + Context.CredentialStore.AddOrUpdate(service, patCredential.Account, patCredential.Password); return patCredential; case AuthenticationModes.OAuth: - return await GenerateOAuthCredentialAsync(targetUri); + return await GenerateOAuthCredentialAsync(remoteUri); default: throw new ArgumentOutOfRangeException(nameof(promptResult)); } } - private async Task GenerateOAuthCredentialAsync(Uri targetUri) + private async Task GenerateOAuthCredentialAsync(Uri targetUri) { OAuth2TokenResult result = await _gitHubAuth.GetOAuthTokenAsync(targetUri, GitHubOAuthScopes); - return new GitCredential(Constants.OAuthTokenUserName, result.AccessToken); + // Resolve the GitHub user handle + GitHubUserInfo userInfo = await _gitHubApi.GetUserInfoAsync(targetUri, result.AccessToken); + + return new GitCredential(userInfo.Login, result.AccessToken); } - private async Task GeneratePersonalAccessTokenAsync(Uri targetUri, ICredential credentials) + private async Task GeneratePersonalAccessTokenAsync(Uri targetUri, ICredential credentials) { AuthenticationResult result = await _gitHubApi.CreatePersonalTokenAsync( targetUri, credentials, null, GitHubCredentialScopes); + string token = null; + if (result.Type == GitHubAuthenticationResultType.Success) { Context.Trace.WriteLine($"Token acquisition for '{targetUri}' succeeded"); - return result.Token; + token = result.Token; } - - if (result.Type == GitHubAuthenticationResultType.TwoFactorApp || - result.Type == GitHubAuthenticationResultType.TwoFactorSms) + else if (result.Type == GitHubAuthenticationResultType.TwoFactorApp || + result.Type == GitHubAuthenticationResultType.TwoFactorSms) { bool isSms = result.Type == GitHubAuthenticationResultType.TwoFactorSms; @@ -140,10 +152,18 @@ private async Task GeneratePersonalAccessTokenAsync(Uri targetUri, { Context.Trace.WriteLine($"Token acquisition for '{targetUri}' succeeded."); - return result.Token; + token = result.Token; } } + if (token != null) + { + // Resolve the GitHub user handle + GitHubUserInfo userInfo = await _gitHubApi.GetUserInfoAsync(targetUri, token); + + return new GitCredential(userInfo.Login, token); + } + throw new Exception($"Interactive logon for '{targetUri}' failed."); } @@ -169,8 +189,8 @@ internal async Task GetSupportedAuthenticationModesAsync(Ur // GitHub.com should use OAuth authentication only if (IsGitHubDotCom(targetUri)) { - Context.Trace.WriteLine($"{targetUri} is github.com - authentication schemes: '{GitHubConstants.DotDomAuthenticationModes}'"); - return GitHubConstants.DotDomAuthenticationModes; + Context.Trace.WriteLine($"{targetUri} is github.com - authentication schemes: '{GitHubConstants.DotComAuthenticationModes}'"); + return GitHubConstants.DotComAuthenticationModes; } // For GitHub Enterprise we must do some detection of supported modes @@ -217,32 +237,21 @@ internal static bool IsGitHubDotCom(Uri targetUri) return StringComparer.OrdinalIgnoreCase.Equals(targetUri.Host, GitHubConstants.GitHubBaseUrlHost); } - private static Uri NormalizeUri(Uri targetUri) + private static Uri NormalizeUri(Uri uri) { - if (targetUri is null) + if (uri is null) { - throw new ArgumentNullException(nameof(targetUri)); + throw new ArgumentNullException(nameof(uri)); } // Special case for gist.github.com which are git backed repositories under the hood. // Credentials for these repositories are the same as the one stored with "github.com" - if (targetUri.DnsSafeHost.Equals(GitHubConstants.GistBaseUrlHost, StringComparison.OrdinalIgnoreCase)) + if (uri.DnsSafeHost.Equals(GitHubConstants.GistBaseUrlHost, StringComparison.OrdinalIgnoreCase)) { return new Uri("https://" + GitHubConstants.GitHubBaseUrlHost); } - return targetUri; - } - - private static Uri GetTargetUri(InputArguments input) - { - Uri uri = new UriBuilder - { - Scheme = input.Protocol, - Host = input.Host, - }.Uri; - - return NormalizeUri(uri); + return uri; } #endregion diff --git a/src/shared/GitHub/GitHubRestApi.cs b/src/shared/GitHub/GitHubRestApi.cs index 7be40d3b6..9e93ec318 100644 --- a/src/shared/GitHub/GitHubRestApi.cs +++ b/src/shared/GitHub/GitHubRestApi.cs @@ -145,8 +145,7 @@ private async Task ParseForbiddenResponseAsync(Uri targetU { _context.Trace.WriteLine($"Authentication success: user supplied personal access token for '{targetUri}'."); - return new AuthenticationResult(GitHubAuthenticationResultType.Success, - new GitCredential(Constants.PersonalAccessTokenUserName, password)); + return new AuthenticationResult(GitHubAuthenticationResultType.Success, password); } _context.Trace.WriteLine($"Authentication failed for '{targetUri}'."); @@ -182,7 +181,7 @@ private AuthenticationResult ParseUnauthorizedResponse(Uri targetUri, string aut private async Task ParseSuccessResponseAsync(Uri targetUri, HttpResponseMessage response) { - GitCredential token = null; + string token = null; string responseText = await response.Content.ReadAsStringAsync(); Match tokenMatch; @@ -190,8 +189,7 @@ private async Task ParseSuccessResponseAsync(Uri targetUri RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)).Success && tokenMatch.Groups.Count > 1) { - string tokenText = tokenMatch.Groups[1].Value; - token = new GitCredential(Constants.PersonalAccessTokenUserName, tokenText); + token = tokenMatch.Groups[1].Value; } if (token == null) @@ -289,7 +287,7 @@ public static Task CreatePersonalTokenAsync( { return api.CreatePersonalAccessTokenAsync( targetUri, - credentials?.UserName, + credentials?.Account, credentials?.Password, authenticationCode, scopes); diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index d40422b21..fef1ed6b4 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -182,17 +182,15 @@ public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathSetTrue_DoesN var provider = new AzureReposHostProvider(new TestCommandContext()); var environment = new TestEnvironment(); - var config = new TestGitConfiguration(new Dictionary> - { - [AzDevUseHttpPathKey] = new List {"true"} - }); + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; await provider.ConfigureAsync( environment, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(git.GlobalConfiguration.Dictionary); + Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); Assert.Single(actualValues); Assert.Equal("true", actualValues[0]); } @@ -203,17 +201,15 @@ public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathSetFalse_Sets var provider = new AzureReposHostProvider(new TestCommandContext()); var environment = new TestEnvironment(); - var config = new TestGitConfiguration(new Dictionary> - { - [AzDevUseHttpPathKey] = new List {"false"} - }); + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"false"}; await provider.ConfigureAsync( environment, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(git.GlobalConfiguration.Dictionary); + Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); Assert.Single(actualValues); Assert.Equal("true", actualValues[0]); } @@ -224,14 +220,14 @@ public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathUnset_SetsUse var provider = new AzureReposHostProvider(new TestCommandContext()); var environment = new TestEnvironment(); - var config = new TestGitConfiguration(); + var git = new TestGit(); await provider.ConfigureAsync( environment, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); + Assert.Single(git.GlobalConfiguration.Dictionary); + Assert.True(git.GlobalConfiguration.Dictionary.TryGetValue(AzDevUseHttpPathKey, out IList actualValues)); Assert.Single(actualValues); Assert.Equal("true", actualValues[0]); } @@ -243,16 +239,14 @@ public async Task AzureReposHostProvider_UnconfigureAsync_UseHttpPathSet_Removes var provider = new AzureReposHostProvider(new TestCommandContext()); var environment = new TestEnvironment(); - var config = new TestGitConfiguration(new Dictionary> - { - [AzDevUseHttpPathKey] = new List {"true"} - }); + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[AzDevUseHttpPathKey] = new List {"true"}; await provider.UnconfigureAsync( environment, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Empty(config.Dictionary); + Assert.Empty(git.GlobalConfiguration.Dictionary); } } } diff --git a/src/shared/Microsoft.AzureRepos.Tests/UriHelpersTests.cs b/src/shared/Microsoft.AzureRepos.Tests/UriHelpersTests.cs index 0b4fc42fb..7fb2839e2 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/UriHelpersTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/UriHelpersTests.cs @@ -37,6 +37,42 @@ public void UriHelpers_IsAzureDevOpsHost(string host, bool expected) Assert.Equal(expected, UriHelpers.IsAzureDevOpsHost(host)); } + [Theory] + [InlineData("dev.azure.com", true)] + [InlineData("myorg.visualstudio.com", false)] + [InlineData("vs-ssh.myorg.visualstudio.com", false)] + [InlineData("DEV.AZURE.COM", true)] + [InlineData("MYORG.VISUALSTUDIO.COM", false)] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("testdev.azure.com", false)] + [InlineData("test.dev.azure.com", false)] + [InlineData("visualstudio.com", false)] + [InlineData("testvisualstudio.com", false)] + public void UriHelpers_IsDevAzureComHost(string host, bool expected) + { + Assert.Equal(expected, UriHelpers.IsDevAzureComHost(host)); + } + + [Theory] + [InlineData("dev.azure.com", false)] + [InlineData("myorg.visualstudio.com", true)] + [InlineData("vs-ssh.myorg.visualstudio.com", true)] + [InlineData("DEV.AZURE.COM", false)] + [InlineData("MYORG.VISUALSTUDIO.COM", true)] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("testdev.azure.com", false)] + [InlineData("test.dev.azure.com", false)] + [InlineData("visualstudio.com", false)] + [InlineData("testvisualstudio.com", false)] + public void UriHelpers_IsVisualStudioComHost(string host, bool expected) + { + Assert.Equal(expected, UriHelpers.IsVisualStudioComHost(host)); + } + [Fact] public void UriHelpers_CreateOrganizationUri_Null_ThrowsException() { @@ -81,6 +117,36 @@ public void UriHelpers_CreateOrganizationUri_AzureHost_ReturnsCorrectUri() Assert.Equal(expected, actual); } + [Fact] + public void UriHelpers_CreateOrganizationUri_AzureHost_WithPort_ReturnsCorrectUri() + { + var expected = new Uri("https://dev.azure.com:456/myorg"); + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com:456", + ["path"] = "myorg/myproject/_git/myrepo" + }); + + Uri actual = UriHelpers.CreateOrganizationUri(input); + + Assert.Equal(expected, actual); + } + + [Fact] + public void UriHelpers_CreateOrganizationUri_AzureHost_WithBadPort_ThrowsException() + { + var expected = new Uri("https://dev.azure.com:456/myorg"); + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com:not-a-port", + ["path"] = "myorg/myproject/_git/myrepo" + }); + + Assert.Throws(() => UriHelpers.CreateOrganizationUri(input)); + } + [Fact] public void UriHelpers_CreateOrganizationUri_AzureHost_OrgAlsoInUser_PrefersPathOrg() { diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index b78e9d93c..bfc2ab1c3 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -10,8 +10,9 @@ namespace Microsoft.AzureRepos { - public class AzureReposHostProvider : HostProvider, IConfigurableComponent + public class AzureReposHostProvider : DisposableObject, IHostProvider, IConfigurableComponent { + private readonly ICommandContext _context; private readonly IAzureDevOpsRestApi _azDevOps; private readonly IMicrosoftAuthentication _msAuth; @@ -22,39 +23,107 @@ public AzureReposHostProvider(ICommandContext context) public AzureReposHostProvider(ICommandContext context, IAzureDevOpsRestApi azDevOps, IMicrosoftAuthentication msAuth) - : base(context) { + EnsureArgument.NotNull(context, nameof(context)); EnsureArgument.NotNull(azDevOps, nameof(azDevOps)); EnsureArgument.NotNull(msAuth, nameof(msAuth)); + _context = context; _azDevOps = azDevOps; _msAuth = msAuth; } - #region HostProvider + #region IHostProvider - public override string Id => "azure-repos"; + public string Id => "azure-repos"; - public override string Name => "Azure Repos"; + public string Name => "Azure Repos"; - public override IEnumerable SupportedAuthorityIds => MicrosoftAuthentication.AuthorityIds; + public IEnumerable SupportedAuthorityIds => MicrosoftAuthentication.AuthorityIds; - public override bool IsSupported(InputArguments input) + public bool IsSupported(InputArguments input) { + if (input is null) + { + return false; + } + // We do not support unencrypted HTTP communications to Azure Repos, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. - return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || - StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && - UriHelpers.IsAzureDevOpsHost(input.Host); + return input.TryGetHostAndPort(out string hostName, out _) + && (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || + StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && + UriHelpers.IsAzureDevOpsHost(hostName); + } + + public async Task GetCredentialAsync(InputArguments input) + { + string service = GetServiceName(input); + string account = GetAccountNameForCredentialQuery(input); + + _context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={account}..."); + + ICredential credential = _context.CredentialStore.Get(service, account); + if (credential == null) + { + _context.Trace.WriteLine("No existing credentials found."); + + // No existing credential was found, create a new one + _context.Trace.WriteLine("Creating new credential..."); + credential = await GenerateCredentialAsync(input); + _context.Trace.WriteLine("Credential created."); + } + else + { + _context.Trace.WriteLine("Existing credential found."); + } + + return credential; + } + + public virtual Task StoreCredentialAsync(InputArguments input) + { + string service = GetServiceName(input); + + // We always store credentials against the given username argument for + // both vs.com and dev.azure.com-style URLs. + string account = input.UserName; + + // Add or update the credential in the store. + _context.Trace.WriteLine($"Storing credential with service={service} account={account}..."); + _context.CredentialStore.AddOrUpdate(service, account, input.Password); + _context.Trace.WriteLine("Credential was successfully stored."); + + return Task.CompletedTask; } - public override string GetCredentialKey(InputArguments input) + public Task EraseCredentialAsync(InputArguments input) { - return $"git:{UriHelpers.CreateOrganizationUri(input).AbsoluteUri}"; + string service = GetServiceName(input); + string account = GetAccountNameForCredentialQuery(input); + + // Try to locate an existing credential + _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={account}..."); + if (_context.CredentialStore.Remove(service, account)) + { + _context.Trace.WriteLine("Credential was successfully erased."); + } + else + { + _context.Trace.WriteLine("No credential was erased."); + } + + return Task.CompletedTask; } - public override async Task GenerateCredentialAsync(InputArguments input) + protected override void ReleaseManagedResources() + { + _azDevOps.Dispose(); + base.ReleaseManagedResources(); + } + + private async Task GenerateCredentialAsync(InputArguments input) { ThrowIfDisposed(); @@ -68,12 +137,12 @@ public override async Task GenerateCredentialAsync(InputArguments i Uri remoteUri = input.GetRemoteUri(); // Determine the MS authentication authority for this organization - Context.Trace.WriteLine("Determining Microsoft Authentication Authority..."); + _context.Trace.WriteLine("Determining Microsoft Authentication Authority..."); string authAuthority = await _azDevOps.GetAuthorityAsync(orgUri); - Context.Trace.WriteLine($"Authority is '{authAuthority}'."); + _context.Trace.WriteLine($"Authority is '{authAuthority}'."); // Get an AAD access token for the Azure DevOps SPS - Context.Trace.WriteLine("Getting Azure AD access token..."); + _context.Trace.WriteLine("Getting Azure AD access token..."); JsonWebToken accessToken = await _msAuth.GetAccessTokenAsync( authAuthority, AzureDevOpsConstants.AadClientId, @@ -82,7 +151,7 @@ public override async Task GenerateCredentialAsync(InputArguments i remoteUri, null); string atUser = accessToken.GetAzureUserName(); - Context.Trace.WriteLineSecrets($"Acquired Azure access token. User='{atUser}' Token='{{0}}'", new object[] {accessToken.EncodedToken}); + _context.Trace.WriteLineSecrets($"Acquired Azure access token. User='{atUser}' Token='{{0}}'", new object[] {accessToken.EncodedToken}); // Ask the Azure DevOps instance to create a new PAT var patScopes = new[] @@ -90,20 +159,89 @@ public override async Task GenerateCredentialAsync(InputArguments i AzureDevOpsConstants.PersonalAccessTokenScopes.ReposWrite, AzureDevOpsConstants.PersonalAccessTokenScopes.ArtifactsRead }; - Context.Trace.WriteLine($"Creating Azure DevOps PAT with scopes '{string.Join(", ", patScopes)}'..."); + _context.Trace.WriteLine($"Creating Azure DevOps PAT with scopes '{string.Join(", ", patScopes)}'..."); string pat = await _azDevOps.CreatePersonalAccessTokenAsync( orgUri, accessToken, patScopes); - Context.Trace.WriteLineSecrets("PAT created. PAT='{0}'", new object[] {pat}); + _context.Trace.WriteLineSecrets("PAT created. PAT='{0}'", new object[] {pat}); - return new GitCredential(Constants.PersonalAccessTokenUserName, pat); + return new GitCredential(atUser, pat); } - protected override void ReleaseManagedResources() + /// + /// For dev.azure.com-style URLs we use the path arg to get the Azure DevOps organization name. + /// We ensure the presence of the path arg by setting credential.useHttpPath = true at install time. + /// + /// The result of this workaround is that we are now unable to determine if the user wanted to store + /// credentials with the full path or not for dev.azure.com-style URLs. + /// + /// Rather than always assume we're storing credentials against the full path, and therefore resulting + /// in an personal access token being created per remote URL/repository, we never store against + /// the full path and always store with the organization URL "dev.azure.com/org". + /// + /// For visualstudio.com-style URLs we know the AzDevOps organization name from the host arg, and + /// don't set the useHttpPath option. This means if we get the full path for a vs.com-style URL + /// we can store against the full remote path (the intended design). + /// + /// Users that need to clone a repository from Azure Repos against the full path therefore must + /// use the vs.com-style remote URL and not the dev.azure.com one. + /// + private static string GetServiceName(InputArguments input) { - _azDevOps.Dispose(); - base.ReleaseManagedResources(); + if (!input.TryGetHostAndPort(out string hostName, out _)) + { + throw new InvalidOperationException("Failed to parse host name and/or port"); + } + + // dev.azure.com + if (UriHelpers.IsDevAzureComHost(hostName)) + { + // We can never store the new dev.azure.com-style URLs against the full path because + // we have forced the useHttpPath option to true to in order to retrieve the AzDevOps + // organization name from Git. + return UriHelpers.CreateOrganizationUri(input).AbsoluteUri.TrimEnd('/'); + } + + // *.visualstudio.com + if (UriHelpers.IsVisualStudioComHost(hostName)) + { + // If we're given the full path for an older *.visualstudio.com-style URL then we should + // respect that in the service name. + return input.GetRemoteUri().AbsoluteUri.TrimEnd('/'); + } + + throw new InvalidOperationException("Host is not Azure DevOps."); + } + + private static string GetAccountNameForCredentialQuery(InputArguments input) + { + if (!input.TryGetHostAndPort(out string hostName, out _)) + { + throw new InvalidOperationException("Failed to parse host name and/or port"); + } + + // dev.azure.com + if (UriHelpers.IsDevAzureComHost(hostName)) + { + // We ignore the given username for dev.azure.com-style URLs because AzDevOps recommends + // adding the organization name as the user in the remote URL (resulting in URLs like + // https://org@dev.azure.com/org/foo/_git/bar) and we don't know if the given username + // is an actual username, or the org name. + // Use `null` as the account name so we match all possible credentials (regardless of + // the account). + return null; + } + + // *.visualstudio.com + if (UriHelpers.IsVisualStudioComHost(hostName)) + { + // If we're given a username for the vs.com-style URLs we can and should respect any + // specified username in the remote URL/input arguments. + return input.UserName; + } + + throw new InvalidOperationException("Host is not Azure DevOps."); } #endregion @@ -114,21 +252,20 @@ protected override void ReleaseManagedResources() public Task ConfigureAsync( IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGitConfiguration configuration, GitConfigurationLevel configurationLevel) + IGit git, GitConfigurationLevel configurationLevel) { string useHttpPathKey = $"{KnownGitCfg.Credential.SectionName}.https://dev.azure.com.{KnownGitCfg.Credential.UseHttpPath}"; - using (IGitConfiguration targetConfig = configuration.GetFilteredConfiguration(configurationLevel)) + IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); + + if (targetConfig.TryGetValue(useHttpPathKey, out string currentValue) && currentValue.IsTruthy()) { - if (targetConfig.TryGetValue(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"); - } + _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"); } return Task.CompletedTask; @@ -136,16 +273,14 @@ public Task ConfigureAsync( public Task UnconfigureAsync( IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGitConfiguration configuration, GitConfigurationLevel configurationLevel) + IGit git, GitConfigurationLevel configurationLevel) { 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..."); + _context.Trace.WriteLine("Clearing Git configuration 'credential.useHttpPath' for https://dev.azure.com..."); - using (IGitConfiguration targetConfig = configuration.GetFilteredConfiguration(configurationLevel)) - { - targetConfig.DeleteEntry(useHttpPathKey); - } + IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); + targetConfig.Unset(useHttpPathKey); return Task.CompletedTask; } diff --git a/src/shared/Microsoft.AzureRepos/UriHelpers.cs b/src/shared/Microsoft.AzureRepos/UriHelpers.cs index 3d74629ed..453a7b49c 100644 --- a/src/shared/Microsoft.AzureRepos/UriHelpers.cs +++ b/src/shared/Microsoft.AzureRepos/UriHelpers.cs @@ -34,6 +34,62 @@ public static string CombinePath(string basePath, string path) return basePath + path; } + /// + /// Check if the hostname is the legacy Azure DevOps hostname (*.visualstudio.com). + /// + /// Git query arguments. + /// True if the hostname is the legacy Azure DevOps host, false otherwise. + public static bool IsVisualStudioComHost(InputArguments input) + { + EnsureArgument.NotNull(input, nameof(input)); + + if (!input.TryGetHostAndPort(out string hostName, out _)) + { + throw new InvalidOperationException("Host name and/or port is invalid."); + } + + return IsVisualStudioComHost(hostName); + } + + /// + /// Check if the hostname is the legacy Azure DevOps hostname (*.visualstudio.com). + /// + /// Hostname to check. + /// True if the hostname is the legacy Azure DevOps host, false otherwise. + public static bool IsVisualStudioComHost(string host) + { + return host != null && + host.EndsWith(AzureDevOpsConstants.VstsHostSuffix, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Check if the hostname is the new Azure DevOps hostname (dev.azure.com). + /// + /// Git query arguments. + /// True if the hostname is the new Azure DevOps host, false otherwise. + public static bool IsDevAzureComHost(InputArguments input) + { + EnsureArgument.NotNull(input, nameof(input)); + + if (!input.TryGetHostAndPort(out string hostName, out _)) + { + throw new InvalidOperationException("Host name and/or port is invalid."); + } + + return IsDevAzureComHost(hostName); + } + + /// + /// Check if the hostname is the new Azure DevOps hostname (dev.azure.com). + /// + /// Hostname to check. + /// True if the hostname is the new Azure DevOps host, false otherwise. + public static bool IsDevAzureComHost(string host) + { + return host != null && + StringComparer.OrdinalIgnoreCase.Equals(host, AzureDevOpsConstants.AzureDevOpsHost); + } + /// /// Check if the hostname is a valid Azure DevOps hostname (dev.azure.com or *.visualstudio.com). /// @@ -41,9 +97,7 @@ public static string CombinePath(string basePath, string path) /// True if the hostname is Azure DevOps, false otherwise. public static bool IsAzureDevOpsHost(string host) { - return host != null && - (StringComparer.OrdinalIgnoreCase.Equals(host, AzureDevOpsConstants.AzureDevOpsHost) || - host.EndsWith(AzureDevOpsConstants.VstsHostSuffix, StringComparison.OrdinalIgnoreCase)); + return IsVisualStudioComHost(host) || IsDevAzureComHost(host); } /// @@ -68,36 +122,45 @@ public static Uri CreateOrganizationUri(InputArguments input) if (string.IsNullOrWhiteSpace(input.Protocol)) { - throw new InvalidOperationException("Input arguments must include protocol"); + throw new InvalidOperationException("Input arguments must include protocol."); } if (string.IsNullOrWhiteSpace(input.Host)) { - throw new InvalidOperationException("Input arguments must include host"); + throw new InvalidOperationException("Input arguments must include host."); } - if (!IsAzureDevOpsHost(input.Host)) + if (!input.TryGetHostAndPort(out string hostName, out int? port)) { - throw new InvalidOperationException("Host is not Azure DevOps"); + throw new InvalidOperationException("Host name and/or port is invalid."); + } + + if (!IsAzureDevOpsHost(hostName)) + { + throw new InvalidOperationException("Host is not Azure DevOps."); } var ub = new UriBuilder { Scheme = input.Protocol, - Host = input.Host, + Host = hostName, }; + if (port.HasValue) + { + ub.Port = port.Value; + } + // Extract the organization name for Azure ('dev.azure.com') style URLs. // The older *.visualstudio.com URLs contained the organization name in the host already. - if (StringComparer.OrdinalIgnoreCase.Equals(input.Host, AzureDevOpsConstants.AzureDevOpsHost)) + if (IsDevAzureComHost(hostName)) { - // dev.azure.com/{org} - string[] pathParts = input.Path?.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries); - if (pathParts?.Length > 0) + // Prefer getting the org name from the path: dev.azure.com/{org} + if (GetFirstPathComponent(input.Path) is string orgName) { - ub.Path = pathParts[0]; + ub.Path = orgName; } - // {org}@dev.azure.com + // Failing that try using the username: {org}@dev.azure.com else if (!string.IsNullOrWhiteSpace(input.UserName)) { ub.Path = input.UserName; @@ -114,5 +177,16 @@ public static Uri CreateOrganizationUri(InputArguments input) return ub.Uri; } + + public static string GetFirstPathComponent(string path) + { + string[] parts = path?.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries); + if (parts?.Length > 0) + { + return parts[0]; + } + + return null; + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs index 2607b7aed..5a0b16350 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs @@ -25,18 +25,18 @@ public async Task Application_ConfigureAsync_HelperSet_DoesNothing() var environment = new Mock(); - var config = new TestGitConfiguration(); - config.Dictionary[key] = new List + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[key] = new List { emptyHelper, gcmConfigName }; await application.ConfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(key, out var actualValues)); + 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]); @@ -54,18 +54,18 @@ public async Task Application_ConfigureAsync_HelperSetWithOthersPreceding_DoesNo var environment = new Mock(); - var config = new TestGitConfiguration(); - config.Dictionary[key] = new List + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[key] = new List { "foo", "bar", emptyHelper, gcmConfigName }; await application.ConfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(key, out var actualValues)); + 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]); @@ -85,18 +85,18 @@ public async Task Application_ConfigureAsync_HelperSetWithOthersFollowing_Clears var environment = new Mock(); - var config = new TestGitConfiguration(); - config.Dictionary[key] = new List + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[key] = new List { "bar", emptyHelper, executablePath, "foo" }; await application.ConfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(key, out var actualValues)); + 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]); @@ -114,14 +114,14 @@ public async Task Application_ConfigureAsync_HelperNotSet_SetsHelper() var environment = new Mock(); - var config = new TestGitConfiguration(); + var git = new TestGit(); await application.ConfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Single(config.Dictionary); - Assert.True(config.Dictionary.TryGetValue(key, out var actualValues)); + 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]); @@ -138,23 +138,21 @@ public async Task Application_UnconfigureAsync_HelperSet_RemovesEntries() IConfigurableComponent application = new Application(new TestCommandContext(), executablePath); var environment = new Mock(); - var config = new TestGitConfiguration(new Dictionary> - { - [key] = new List {emptyHelper, gcmConfigName} - }); + var git = new TestGit(); + git.GlobalConfiguration.Dictionary[key] = new List {emptyHelper, gcmConfigName}; await application.UnconfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); - Assert.Empty(config.Dictionary); + Assert.Empty(git.GlobalConfiguration.Dictionary); } #endregion #region Windows-specific configuration tests - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public async Task Application_ConfigureAsync_User_PathSet_DoesNothing() { const string directoryPath = @"X:\Install Location"; @@ -165,16 +163,16 @@ public async Task Application_ConfigureAsync_User_PathSet_DoesNothing() var environment = new Mock(); environment.Setup(x => x.IsDirectoryOnPath(directoryPath)).Returns(true); - var config = new TestGitConfiguration(); + var git = new TestGit(); await application.ConfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); environment.Verify(x => x.AddDirectoryToPath(It.IsAny(), It.IsAny()), Times.Never); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public async Task Application_ConfigureAsync_User_PathNotSet_SetsUserPath() { const string directoryPath = @"X:\Install Location"; @@ -185,16 +183,16 @@ public async Task Application_ConfigureAsync_User_PathNotSet_SetsUserPath() var environment = new Mock(); environment.Setup(x => x.IsDirectoryOnPath(directoryPath)).Returns(false); - var config = new TestGitConfiguration(); + var git = new TestGit(); await application.ConfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); environment.Verify(x => x.AddDirectoryToPath(directoryPath, EnvironmentVariableTarget.User), Times.Once); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public async Task Application_UnconfigureAsync_User_PathSet_RemovesFromUserPath() { const string directoryPath = @"X:\Install Location"; @@ -205,11 +203,11 @@ public async Task Application_UnconfigureAsync_User_PathSet_RemovesFromUserPath( var environment = new Mock(); environment.Setup(x => x.IsDirectoryOnPath(directoryPath)).Returns(true); - var config = new TestGitConfiguration(); + var git = new TestGit(); await application.UnconfigureAsync( environment.Object, EnvironmentVariableTarget.User, - config, GitConfigurationLevel.Global); + git, GitConfigurationLevel.Global); environment.Verify(x => x.RemoveDirectoryFromPath(directoryPath, EnvironmentVariableTarget.User), Times.Once); } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs index bbe3ea2a9..ca3865124 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs @@ -33,7 +33,7 @@ public void BasicAuthentication_GetCredentials_NonDesktopSession_ResourceAndUser ICredential credential = basicAuth.GetCredentials(testResource, testUserName); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } @@ -52,7 +52,7 @@ public void BasicAuthentication_GetCredentials_NonDesktopSession_Resource_UserPa ICredential credential = basicAuth.GetCredentials(testResource); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } @@ -72,7 +72,7 @@ public void BasicAuthentication_GetCredentials_NonDesktopSession_NoTerminalPromp Assert.Throws(() => basicAuth.GetCredentials(testResource)); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public void BasicAuthentication_GetCredentials_DesktopSession_Resource_UserPassPromptReturnsCredentials() { const string testResource = "https://example.com"; @@ -99,11 +99,11 @@ public void BasicAuthentication_GetCredentials_DesktopSession_Resource_UserPassP ICredential credential = basicAuth.GetCredentials(testResource); Assert.NotNull(credential); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_PassPromptReturnsCredentials() { const string testResource = "https://example.com"; @@ -130,11 +130,11 @@ public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_Pa ICredential credential = basicAuth.GetCredentials(testResource, testUserName); Assert.NotNull(credential); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_PassPromptDiffUserReturnsCredentials() { const string testResource = "https://example.com"; @@ -162,7 +162,7 @@ public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_Pa ICredential credential = basicAuth.GetCredentials(testResource, testUserName); Assert.NotNull(credential); - Assert.Equal(newUserName, credential.UserName); + Assert.Equal(newUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs index 5fecc3691..ea84a6b08 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/ConfigurationServiceTests.cs @@ -28,15 +28,15 @@ public async Task ConfigurationService_ConfigureAsync_System_ComponentsAreConfig component1.Verify(x => x.ConfigureAsync( context.Environment, EnvironmentVariableTarget.Machine, - It.IsAny(), GitConfigurationLevel.System), + context.Git, GitConfigurationLevel.System), Times.Once); component2.Verify(x => x.ConfigureAsync( context.Environment, EnvironmentVariableTarget.Machine, - It.IsAny(), GitConfigurationLevel.System), + context.Git, GitConfigurationLevel.System), Times.Once); component3.Verify(x => x.ConfigureAsync( context.Environment, EnvironmentVariableTarget.Machine, - It.IsAny(), GitConfigurationLevel.System), + context.Git, GitConfigurationLevel.System), Times.Once); } @@ -58,15 +58,15 @@ public async Task ConfigurationService_ConfigureAsync_User_ComponentsAreConfigur component1.Verify(x => x.ConfigureAsync( context.Environment, EnvironmentVariableTarget.User, - It.IsAny(), GitConfigurationLevel.Global), + context.Git, GitConfigurationLevel.Global), Times.Once); component2.Verify(x => x.ConfigureAsync( context.Environment, EnvironmentVariableTarget.User, - It.IsAny(), GitConfigurationLevel.Global), + context.Git, GitConfigurationLevel.Global), Times.Once); component3.Verify(x => x.ConfigureAsync( context.Environment, EnvironmentVariableTarget.User, - It.IsAny(), GitConfigurationLevel.Global), + context.Git, GitConfigurationLevel.Global), Times.Once); } @@ -88,15 +88,15 @@ public async Task ConfigurationService_UnconfigureAsync_System_ComponentsAreUnco component1.Verify(x => x.UnconfigureAsync( context.Environment, EnvironmentVariableTarget.Machine, - It.IsAny(), GitConfigurationLevel.System), + context.Git, GitConfigurationLevel.System), Times.Once); component2.Verify(x => x.UnconfigureAsync( context.Environment, EnvironmentVariableTarget.Machine, - It.IsAny(), GitConfigurationLevel.System), + context.Git, GitConfigurationLevel.System), Times.Once); component3.Verify(x => x.UnconfigureAsync( context.Environment, EnvironmentVariableTarget.Machine, - It.IsAny(), GitConfigurationLevel.System), + context.Git, GitConfigurationLevel.System), Times.Once); } @@ -118,15 +118,15 @@ public async Task ConfigurationService_UnconfigureAsync_User_ComponentsAreUnconf component1.Verify(x => x.UnconfigureAsync( context.Environment, EnvironmentVariableTarget.User, - It.IsAny(), GitConfigurationLevel.Global), + context.Git, GitConfigurationLevel.Global), Times.Once); component2.Verify(x => x.UnconfigureAsync( context.Environment, EnvironmentVariableTarget.User, - It.IsAny(), GitConfigurationLevel.Global), + context.Git, GitConfigurationLevel.Global), Times.Once); component3.Verify(x => x.UnconfigureAsync( context.Environment, EnvironmentVariableTarget.User, - It.IsAny(), GitConfigurationLevel.Global), + context.Git, GitConfigurationLevel.Global), Times.Once); } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/GenericHostProviderTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/GenericHostProviderTests.cs index ebb9a8b4f..9f921d5d5 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/GenericHostProviderTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/GenericHostProviderTests.cs @@ -39,9 +39,9 @@ public void GenericHostProvider_IsSupported(string protocol, bool expected) } [Fact] - public void GenericHostProvider_GetCredentialKey_ReturnsCorrectKey() + public void GenericHostProvider_GetCredentialServiceUrl_ReturnsCorrectKey() { - const string expectedKey = "git:https://john.doe@example.com/foo/bar"; + const string expectedService = "https://example.com/foo/bar"; var input = new InputArguments(new Dictionary { @@ -53,9 +53,9 @@ public void GenericHostProvider_GetCredentialKey_ReturnsCorrectKey() var provider = new GenericHostProvider(new TestCommandContext()); - string actualKey = provider.GetCredentialKey(input); + string actualService = provider.GetServiceName(input); - Assert.Equal(expectedKey, actualKey); + Assert.Equal(expectedService, actualService); } [Fact] @@ -86,7 +86,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); @@ -121,19 +121,19 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); } - [PlatformFact(Platform.MacOS, Platform.Linux)] + [PlatformFact(Platforms.Posix)] public async Task GenericHostProvider_CreateCredentialAsync_NonWindows_WiaSupported_ReturnsBasicCredential() { await TestCreateCredentialAsync_ReturnsBasicCredential(wiaSupported: true); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public async Task GenericHostProvider_CreateCredentialAsync_Windows_WiaSupported_ReturnsEmptyCredential() { await TestCreateCredentialAsync_ReturnsEmptyCredential(wiaSupported: true); @@ -168,7 +168,7 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(string.Empty, credential.UserName); + Assert.Equal(string.Empty, credential.Account); Assert.Equal(string.Empty, credential.Password); basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Never); } @@ -199,7 +199,7 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool ICredential credential = await provider.GenerateCredentialAsync(input); Assert.NotNull(credential); - Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs new file mode 100644 index 000000000..03358789f --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/GitConfigurationTests.cs @@ -0,0 +1,433 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Microsoft.Git.CredentialManager.Tests.Objects; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class GitConfigurationTests + { + [Fact] + public void GitProcess_GetConfiguration_ReturnsConfiguration() + { + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath); + var config = git.GetConfiguration(); + Assert.NotNull(config); + } + + [Fact] + public void GitConfiguration_Enumerate_CallbackReturnsTrue_InvokesCallbackForEachEntry() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local foo.name lancelot").AssertSuccess(); + Git(repoPath, workDirPath, "config --local foo.quest seek-holy-grail").AssertSuccess(); + Git(repoPath, workDirPath, "config --local foo.favcolor blue").AssertSuccess(); + + var expectedVisitedEntries = new List<(string name, string value)> + { + ("foo.name", "lancelot"), + ("foo.quest", "seek-holy-grail"), + ("foo.favcolor", "blue") + }; + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var actualVisitedEntries = new List<(string name, string value)>(); + + bool cb(string name, string value) + { + if (name.StartsWith("foo.")) + { + actualVisitedEntries.Add((name, value)); + } + + // Continue enumeration + return true; + } + + config.Enumerate(cb); + + Assert.Equal(expectedVisitedEntries, actualVisitedEntries); + } + + [Fact] + public void GitConfiguration_Enumerate_CallbackReturnsFalse_InvokesCallbackForEachEntryUntilReturnsFalse() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local foo.name lancelot").AssertSuccess(); + Git(repoPath, workDirPath, "config --local foo.quest seek-holy-grail").AssertSuccess(); + Git(repoPath, workDirPath, "config --local foo.favcolor blue").AssertSuccess(); + + var expectedVisitedEntries = new List<(string name, string value)> + { + ("foo.name", "lancelot"), + ("foo.quest", "seek-holy-grail") + }; + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var actualVisitedEntries = new List<(string name, string value)>(); + + bool cb(string name, string value) + { + if (name.StartsWith("foo.")) + { + actualVisitedEntries.Add((name, value)); + } + + // Stop enumeration after 2 'foo' entries + return actualVisitedEntries.Count < 2; + } + + config.Enumerate(cb); + + Assert.Equal(expectedVisitedEntries, actualVisitedEntries); + } + + [Fact] + public void GitConfiguration_TryGetValue_Name_Exists_ReturnsTrueOutString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + bool result = config.TryGetValue("user.name", out string value); + Assert.True(result); + Assert.NotNull(value); + Assert.Equal("john.doe", value); + } + + [Fact] + public void GitConfiguration_TryGetValue_Name_DoesNotExists_ReturnsFalse() + { + string repoPath = CreateRepository(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string randomName = $"{Guid.NewGuid():N}.{Guid.NewGuid():N}"; + bool result = config.TryGetValue(randomName, out string value); + Assert.False(result); + Assert.Null(value); + } + + [Fact] + public void GitConfiguration_TryGetValue_SectionProperty_Exists_ReturnsTrueOutString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + bool result = config.TryGetValue("user", "name", out string value); + Assert.True(result); + Assert.NotNull(value); + Assert.Equal("john.doe", value); + } + + [Fact] + public void GitConfiguration_TryGetValue_SectionProperty_DoesNotExists_ReturnsFalse() + { + string repoPath = CreateRepository(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string randomSection = Guid.NewGuid().ToString("N"); + string randomProperty = Guid.NewGuid().ToString("N"); + bool result = config.TryGetValue(randomSection, randomProperty, out string value); + Assert.False(result); + Assert.Null(value); + } + + [Fact] + public void GitConfiguration_TryGetValue_SectionScopeProperty_Exists_ReturnsTrueOutString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.example.com.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + bool result = config.TryGetValue("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() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + bool result = config.TryGetValue("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() + { + string repoPath = CreateRepository(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + 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); + Assert.False(result); + Assert.Null(value); + } + + [Fact] + public void GitConfiguration_GetString_Name_Exists_ReturnsString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string value = config.GetValue("user.name"); + Assert.NotNull(value); + Assert.Equal("john.doe", value); + } + + [Fact] + public void GitConfiguration_GetString_Name_DoesNotExists_ThrowsException() + { + string repoPath = CreateRepository(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string randomName = $"{Guid.NewGuid():N}.{Guid.NewGuid():N}"; + Assert.Throws(() => config.GetValue(randomName)); + } + + [Fact] + public void GitConfiguration_GetString_SectionProperty_Exists_ReturnsString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string value = config.GetValue("user", "name"); + Assert.NotNull(value); + Assert.Equal("john.doe", value); + } + + [Fact] + public void GitConfiguration_GetString_SectionProperty_DoesNotExists_ThrowsException() + { + string repoPath = CreateRepository(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string randomSection = Guid.NewGuid().ToString("N"); + string randomProperty = Guid.NewGuid().ToString("N"); + Assert.Throws(() => config.GetValue(randomSection, randomProperty)); + } + + [Fact] + public void GitConfiguration_GetString_SectionScopeProperty_Exists_ReturnsString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.example.com.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string value = config.GetValue("user", "example.com", "name"); + Assert.NotNull(value); + Assert.Equal("john.doe", value); + } + + [Fact] + public void GitConfiguration_GetString_SectionScopeProperty_NullScope_ReturnsUnscopedString() + { + string repoPath = CreateRepository(out string workDirPath); + Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + string value = config.GetValue("user", null, "name"); + Assert.NotNull(value); + Assert.Equal("john.doe", value); + } + + [Fact] + public void GitConfiguration_GetString_SectionScopeProperty_DoesNotExists_ThrowsException() + { + string repoPath = CreateRepository(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var git = new GitProcess(trace, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + 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)); + } + + #region Test helpers + + private static string GetGitPath() + { + ProcessStartInfo psi; + if (PlatformUtils.IsWindows()) + { + psi = new ProcessStartInfo( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "where.exe"), + "git.exe" + ); + } + else + { + psi = new ProcessStartInfo("/usr/bin/which", "git"); + } + + psi.RedirectStandardOutput = true; + + using (var which = new Process {StartInfo = psi}) + { + which.Start(); + which.WaitForExit(); + + if (which.ExitCode != 0) + { + throw new Exception("Failed to locate Git"); + } + + string data = which.StandardOutput.ReadLine(); + + if (string.IsNullOrWhiteSpace(data)) + { + throw new Exception("Failed to locate Git on the PATH"); + } + + return data; + } + } + + private static string CreateRepository() => CreateRepository(out _); + + private static string CreateRepository(out string workDirPath) + { + string tempDirectory = Path.GetTempPath(); + string repoName = $"repo-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + workDirPath = Path.Combine(tempDirectory, repoName); + string gitDirPath = Path.Combine(workDirPath, ".git"); + + if (Directory.Exists(workDirPath)) + { + Directory.Delete(workDirPath); + } + + Directory.CreateDirectory(workDirPath); + + Git(gitDirPath, workDirPath, "init").AssertSuccess(); + + return gitDirPath; + } + + private static GitResult Git(string repositoryPath, string workingDirectory, string command) + { + var procInfo = new ProcessStartInfo("git", command) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }; + + procInfo.Environment["GIT_DIR"] = repositoryPath; + + Process proc = Process.Start(procInfo); + if (proc is null) + { + throw new Exception("Failed to start Git process"); + } + + proc.WaitForExit(); + + var result = new GitResult + { + ExitCode = proc.ExitCode, + StandardOutput = proc.StandardOutput.ReadToEnd(), + StandardError = proc.StandardError.ReadToEnd() + }; + + return result; + } + + private struct GitResult + { + public int ExitCode; + public string StandardOutput; + public string StandardError; + + public void AssertSuccess() + { + Assert.Equal(0, ExitCode); + } + } + + #endregion + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/HostProviderTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/HostProviderTests.cs index d8cdbca2a..c35a528c4 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/HostProviderTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/HostProviderTests.cs @@ -14,23 +14,22 @@ public class HostProviderTests [Fact] public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExistingCredential() { - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; - const string testCredentialKey = "git:test-cred-key"; + const string userName = "john.doe"; + const string password = "letmein123"; + const string service = "https://example.com"; var input = new InputArguments(new Dictionary { - ["username"] = testUserName, - ["password"] = testPassword, + ["protocol"] = "https", + ["host"] = "example.com", + ["username"] = userName, + ["password"] = password, }); - var context = new TestCommandContext - { - CredentialStore = {[testCredentialKey] = new GitCredential(testUserName, testPassword)} - }; + var context = new TestCommandContext(); + context.CredentialStore.Add(service, userName, password); var provider = new TestHostProvider(context) { IsSupportedFunc = _ => true, - CredentialKey = testCredentialKey, GenerateCredentialFunc = _ => { Assert.True(false, "Should never be called"); @@ -40,20 +39,21 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti ICredential actualCredential = await ((IHostProvider) provider).GetCredentialAsync(input); - Assert.Equal(testUserName, actualCredential.UserName); - Assert.Equal(testPassword, actualCredential.Password); + Assert.Equal(userName, actualCredential.Account); + Assert.Equal(password, actualCredential.Password); } [Fact] public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_ReturnsNewGeneratedCredential() { - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; - const string testCredentialKey = "git:test-cred-key"; + const string userName = "john.doe"; + const string password = "letmein123"; var input = new InputArguments(new Dictionary { - ["username"] = testUserName, - ["password"] = testPassword, + ["protocol"] = "https", + ["host"] = "example.com", + ["username"] = userName, + ["password"] = password, }); bool generateWasCalled = false; @@ -61,19 +61,18 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns var provider = new TestHostProvider(context) { IsSupportedFunc = _ => true, - CredentialKey = testCredentialKey, GenerateCredentialFunc = _ => { generateWasCalled = true; - return new GitCredential(testUserName, testPassword); + return new GitCredential(userName, password); }, }; ICredential actualCredential = await ((IHostProvider) provider).GetCredentialAsync(input); Assert.True(generateWasCalled); - Assert.Equal(testUserName, actualCredential.UserName); - Assert.Equal(testPassword, actualCredential.Password); + Assert.Equal(userName, actualCredential.Account); + Assert.Equal(password, actualCredential.Password); } @@ -86,42 +85,45 @@ public async Task HostProvider_StoreCredentialAsync_EmptyCredential_DoesNotStore { const string emptyUserName = ""; const string emptyPassword = ""; - const string testCredentialKey = "git:test-cred-key"; var input = new InputArguments(new Dictionary { + ["protocol"] = "https", + ["host"] = "example.com", ["username"] = emptyUserName, ["password"] = emptyPassword, }); var context = new TestCommandContext(); - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + var provider = new TestHostProvider(context); await ((IHostProvider) provider).StoreCredentialAsync(input); - Assert.Empty(context.CredentialStore); + Assert.Equal(0, context.CredentialStore.Count); } [Fact] public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_StoresCredential() { - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; - const string testCredentialKey = "git:test-cred-key"; + const string userName = "john.doe"; + const string password = "letmein123"; + const string service = "https://example.com"; var input = new InputArguments(new Dictionary { - ["username"] = testUserName, - ["password"] = testPassword, + ["protocol"] = "https", + ["host"] = "example.com", + ["username"] = userName, + ["password"] = password, }); var context = new TestCommandContext(); - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + var provider = new TestHostProvider(context); await ((IHostProvider) provider).StoreCredentialAsync(input); - Assert.Single(context.CredentialStore); - Assert.True(context.CredentialStore.TryGetValue(testCredentialKey, out ICredential storedCredential)); - Assert.Equal(testUserName, storedCredential.UserName); - Assert.Equal(testPassword, storedCredential.Password); + Assert.Equal(1, context.CredentialStore.Count); + Assert.True(context.CredentialStore.TryGet(service, userName, out var storedCredential)); + Assert.Equal(userName, storedCredential.Account); + Assert.Equal(password, storedCredential.Password); } [Fact] @@ -130,24 +132,24 @@ public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_ExistingC const string testUserName = "john.doe"; const string testPasswordOld = "letmein123-old"; const string testPasswordNew = "letmein123-new"; - const string testCredentialKey = "git:test-cred-key"; + const string testService = "https://example.com"; var input = new InputArguments(new Dictionary { + ["protocol"] = "https", + ["host"] = "example.com", ["username"] = testUserName, ["password"] = testPasswordNew, }); - var context = new TestCommandContext - { - CredentialStore = {[testCredentialKey] = new GitCredential(testUserName, testPasswordOld)} - }; - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + var context = new TestCommandContext(); + context.CredentialStore.Add(testService, testUserName, testPasswordOld); + var provider = new TestHostProvider(context); await ((IHostProvider) provider).StoreCredentialAsync(input); - Assert.Single(context.CredentialStore); - Assert.True(context.CredentialStore.TryGetValue(testCredentialKey, out ICredential storedCredential)); - Assert.Equal(testUserName, storedCredential.UserName); + Assert.Equal(1, context.CredentialStore.Count); + Assert.True(context.CredentialStore.TryGet(testService, testUserName, out var storedCredential)); + Assert.Equal(testUserName, storedCredential.Account); Assert.Equal(testPasswordNew, storedCredential.Password); } @@ -156,130 +158,100 @@ public async Task HostProvider_StoreCredentialAsync_NonEmptyCredential_ExistingC #region EraseCredentialAsync [Fact] - public async Task HostProvider_EraseCredentialAsync_NoInputUserPass_CredentialExists_ErasesCredential() + public async Task HostProvider_EraseCredentialAsync_NoInputUser_CredentialExists_ErasesOneCredential() { - const string testCredentialKey = "git:test-cred-key"; - const string otherCredentialKey1 = "git:credential1"; - const string otherCredentialKey2 = "git:credential2"; - var input = new InputArguments(new Dictionary()); - - var context = new TestCommandContext + const string service = "https://example.com"; + const string userName1 = "john.doe"; + const string userName2 = "alice"; + const string userName3 = "bob"; + var input = new InputArguments(new Dictionary { - CredentialStore = - { - [testCredentialKey] = new GitCredential("john.doe", "letmein123"), - [otherCredentialKey1] = new GitCredential("this.should-1", "not.be.erased-1"), - [otherCredentialKey2] = new GitCredential("this.should-2", "not.be.erased-2") - } - }; - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + ["protocol"] = "https", + ["host"] = "example.com", + }); + + var context = new TestCommandContext(); + context.CredentialStore.Add(service, userName1, "letmein123"); + context.CredentialStore.Add(service, userName2, "do-not-erase-me"); + context.CredentialStore.Add(service, userName3, "here-forever"); + var provider = new TestHostProvider(context); await ((IHostProvider) provider).EraseCredentialAsync(input); Assert.Equal(2, context.CredentialStore.Count); - Assert.False(context.CredentialStore.ContainsKey(testCredentialKey)); - Assert.True(context.CredentialStore.ContainsKey(otherCredentialKey1)); - Assert.True(context.CredentialStore.ContainsKey(otherCredentialKey2)); } [Fact] - public async Task HostProvider_EraseCredentialAsync_InputUserPass_CredentialExists_UserNotMatch_DoesNothing() + public async Task HostProvider_EraseCredentialAsync_InputUser_CredentialExists_UserNotMatch_DoesNothing() { - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; - const string testCredentialKey = "git:test-cred-key"; + const string userName1 = "john.doe"; + const string userName2 = "alice"; + const string password = "letmein123"; + const string service = "https://example.com"; var input = new InputArguments(new Dictionary { - ["username"] = testUserName, - ["password"] = testPassword, + ["protocol"] = "https", + ["host"] = "example.com", + ["username"] = userName1, + ["password"] = password, }); - var context = new TestCommandContext - { - CredentialStore = {[testCredentialKey] = new GitCredential("different-username", testPassword)} - }; - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + var context = new TestCommandContext(); + context.CredentialStore.Add(service, userName2, password); + var provider = new TestHostProvider(context); await ((IHostProvider) provider).EraseCredentialAsync(input); - Assert.Single(context.CredentialStore); - Assert.True(context.CredentialStore.ContainsKey(testCredentialKey)); + Assert.Equal(1, context.CredentialStore.Count); + Assert.True(context.CredentialStore.Contains(service, userName2)); } [Fact] - public async Task HostProvider_EraseCredentialAsync_InputUserPass_CredentialExists_PassNotMatch_DoesNothing() + public async Task HostProvider_EraseCredentialAsync_InputUser_CredentialExists_UserMatch_ErasesCredential() { - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; - const string testCredentialKey = "git:test-cred-key"; + const string userName = "john.doe"; + const string password = "letmein123"; + const string service = "https://example.com"; var input = new InputArguments(new Dictionary { - ["username"] = testUserName, - ["password"] = testPassword, + ["protocol"] = "https", + ["host"] = "example.com", + ["username"] = userName, + ["password"] = password, }); - var context = new TestCommandContext - { - CredentialStore = - { - [testCredentialKey] = new GitCredential(testUserName, "different-password"), - } - }; - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + var context = new TestCommandContext(); + context.CredentialStore.Add(service, userName, password); + var provider = new TestHostProvider(context); await ((IHostProvider) provider).EraseCredentialAsync(input); - Assert.Single(context.CredentialStore); - Assert.True(context.CredentialStore.ContainsKey(testCredentialKey)); + Assert.Equal(0, context.CredentialStore.Count); + Assert.False(context.CredentialStore.Contains(service, userName)); } [Fact] - public async Task HostProvider_EraseCredentialAsync_InputUserPass_CredentialExists_UserPassMatch_ErasesCredential() + public async Task HostProvider_EraseCredentialAsync_DifferentHost_DoesNothing() { - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; - const string testCredentialKey = "git:test-cred-key"; + const string service2 = "https://example2.com"; + const string service3 = "https://example3.com"; + const string userName = "john.doe"; var input = new InputArguments(new Dictionary { - ["username"] = testUserName, - ["password"] = testPassword, + ["protocol"] = "https", + ["host"] = "example1.com", }); - var context = new TestCommandContext - { - CredentialStore = {[testCredentialKey] = new GitCredential(testUserName, testPassword)} - }; - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; - - await ((IHostProvider) provider).EraseCredentialAsync(input); - - Assert.Empty(context.CredentialStore); - Assert.False(context.CredentialStore.ContainsKey(testCredentialKey)); - } - - [Fact] - public async Task HostProvider_EraseCredentialAsync_NoCredential_DoesNothing() - { - const string testCredentialKey = "git:test-cred-key"; - const string otherCredentialKey1 = "git:credential1"; - const string otherCredentialKey2 = "git:credential2"; - var input = new InputArguments(new Dictionary()); - - var context = new TestCommandContext - { - CredentialStore = - { - [otherCredentialKey1] = new GitCredential("this.should-1", "not.be.erased-1"), - [otherCredentialKey2] = new GitCredential("this.should-2", "not.be.erased-2") - } - }; - var provider = new TestHostProvider(context) {CredentialKey = testCredentialKey}; + var context = new TestCommandContext(); + context.CredentialStore.Add(service2, userName, "keep-me"); + context.CredentialStore.Add(service3, userName, "also-keep-me"); + var provider = new TestHostProvider(context); await ((IHostProvider) provider).EraseCredentialAsync(input); Assert.Equal(2, context.CredentialStore.Count); - Assert.True(context.CredentialStore.ContainsKey(otherCredentialKey1)); - Assert.True(context.CredentialStore.ContainsKey(otherCredentialKey2)); + Assert.True(context.CredentialStore.Contains(service2, userName)); + Assert.True(context.CredentialStore.Contains(service3, userName)); } #endregion diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs index dbd2ca035..4429b0e58 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs @@ -94,6 +94,71 @@ public void InputArguments_GetRemoteUri_Authority_ReturnsUriWithAuthority() Assert.Equal(expectedUri, actualUri); } + [Fact] + public void InputArguments_GetRemoteUri_IncludeUser_Authority_ReturnsUriWithAuthorityAndUser() + { + var expectedUri = new Uri("https://john.doe@example.com/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + + // Username should appear in the returned URI; the password should not + ["username"] = "john.doe", + ["password"] = "password123" + }; + + var inputArgs = new InputArguments(dict); + + Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void InputArguments_GetRemoteUri_IncludeUserSpecialCharacters_Authority_ReturnsUriWithAuthorityAndUser() + { + var expectedUri = new Uri("https://john.doe%40domain.com@example.com/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + + // Username should appear in the returned URI; the password should not + ["username"] = "john.doe@domain.com", + ["password"] = "password123" + }; + + var inputArgs = new InputArguments(dict); + + Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void InputArguments_GetRemoteUri_AuthorityAndPort_ReturnsUriWithAuthorityAndPort() + { + var expectedUri = new Uri("https://example.com:456/"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com:456" + }; + + var inputArgs = new InputArguments(dict); + + Uri actualUri = inputArgs.GetRemoteUri(); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + [Fact] public void InputArguments_GetRemoteUri_AuthorityPath_ReturnsUriWithAuthorityAndPath() { @@ -137,5 +202,105 @@ public void InputArguments_GetRemoteUri_AuthorityPathUserInfo_ReturnsUriWithAuth Assert.NotNull(actualUri); Assert.Equal(expectedUri, actualUri); } + + [Fact] + public void InputArguments_GetRemoteUri_IncludeUser_AuthorityPathUserInfo_ReturnsUriWithAll() + { + var expectedUri = new Uri("https://john.doe@example.com/an/example/path"); + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + ["path"] = "an/example/path", + + // Username should appear in the returned URI; the password should not + ["username"] = "john.doe", + ["password"] = "password123" + }; + + var inputArgs = new InputArguments(dict); + + Uri actualUri = inputArgs.GetRemoteUri(includeUser: true); + + Assert.NotNull(actualUri); + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void InputArguments_TryGetHostAndPort_NoPort_ReturnsHostName() + { + const string expectedHostName = "example.com"; + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com" + }; + + var inputArgs = new InputArguments(dict); + + bool result = inputArgs.TryGetHostAndPort(out string actualHostName, out int? actualPort); + + Assert.True(result); + Assert.NotNull(actualHostName); + Assert.Equal(expectedHostName, actualHostName); + Assert.Null(actualPort); + } + + [Fact] + public void InputArguments_TryGetHostAndPort_Port_ReturnsHostNameAndPort() + { + const string expectedHostName = "example.com"; + const int expectedPort = 456; + + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com:456" + }; + + var inputArgs = new InputArguments(dict); + + bool result = inputArgs.TryGetHostAndPort(out string actualHostName, out int? actualPort); + + Assert.True(result); + Assert.NotNull(actualHostName); + Assert.Equal(expectedHostName, actualHostName); + Assert.NotNull(actualPort); + Assert.Equal(expectedPort, actualPort); + } + + [Fact] + public void InputArguments_TryGetHostAndPort_BadPort_ReturnsFalse() + { + var dict = new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com:not-a-port" + }; + + var inputArgs = new InputArguments(dict); + + bool result = inputArgs.TryGetHostAndPort(out _, out int? actualPort); + + Assert.False(result); + Assert.Null(actualPort); + } + + [Fact] + public void InputArguments_TryGetHostAndPort_NoHostNoPort_ReturnsFalse() + { + var dict = new Dictionary + { + ["protocol"] = "https", + }; + + var inputArgs = new InputArguments(dict); + + bool result = inputArgs.TryGetHostAndPort(out _, out _); + + Assert.False(result); + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/LibGit2Tests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/LibGit2Tests.cs deleted file mode 100644 index 7a8632666..000000000 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/LibGit2Tests.cs +++ /dev/null @@ -1,509 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text; -using Microsoft.Git.CredentialManager.Interop; -using Microsoft.Git.CredentialManager.Interop.Posix.Native; -using Microsoft.Git.CredentialManager.Tests.Objects; -using Xunit; - -namespace Microsoft.Git.CredentialManager.Tests.Interop -{ - public class LibGit2Tests - { - [Fact] - public void LibGit2_GetRepositoryPath_NotInsideRepository_ReturnsNull() - { - var trace = new NullTrace(); - var git = new LibGit2(trace); - string randomPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}"); - Directory.CreateDirectory(randomPath); - - string repositoryPath = git.GetRepositoryPath(randomPath); - - Assert.Null(repositoryPath); - } - - [Fact] - public void LibGit2_GetConfiguration_ReturnsConfiguration() - { - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration()) - { - Assert.NotNull(config); - } - } - - [Fact] - public void LibGit2Configuration_Enumerate_CallbackReturnsTrue_InvokesCallbackForEachEntry() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local foo.name lancelot").AssertSuccess(); - Git(repoPath, workDirPath, "config --local foo.quest seek-holy-grail").AssertSuccess(); - Git(repoPath, workDirPath, "config --local foo.favcolor blue").AssertSuccess(); - - var expectedVisitedEntries = new List<(string name, string value)> - { - ("foo.name", "lancelot"), - ("foo.quest", "seek-holy-grail"), - ("foo.favcolor", "blue") - }; - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - var actualVisitedEntries = new List<(string name, string value)>(); - - bool cb(string name, string value) - { - if (name.StartsWith("foo.")) - { - actualVisitedEntries.Add((name, value)); - } - - // Continue enumeration - return true; - } - - config.Enumerate(cb); - - Assert.Equal(expectedVisitedEntries, actualVisitedEntries); - } - } - - [Fact] - public void LibGit2Configuration_Enumerate_CallbackReturnsFalse_InvokesCallbackForEachEntryUntilReturnsFalse() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local foo.name lancelot").AssertSuccess(); - Git(repoPath, workDirPath, "config --local foo.quest seek-holy-grail").AssertSuccess(); - Git(repoPath, workDirPath, "config --local foo.favcolor blue").AssertSuccess(); - - var expectedVisitedEntries = new List<(string name, string value)> - { - ("foo.name", "lancelot"), - ("foo.quest", "seek-holy-grail") - }; - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - var actualVisitedEntries = new List<(string name, string value)>(); - - bool cb(string name, string value) - { - if (name.StartsWith("foo.")) - { - actualVisitedEntries.Add((name, value)); - } - - // Stop enumeration after 2 'foo' entries - return actualVisitedEntries.Count < 2; - } - - config.Enumerate(cb); - - Assert.Equal(expectedVisitedEntries, actualVisitedEntries); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_Name_Exists_ReturnsTrueOutString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - bool result = config.TryGetValue("user.name", out string value); - Assert.True(result); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_Name_DoesNotExists_ReturnsFalse() - { - string repoPath = CreateRepository(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string randomName = $"{Guid.NewGuid():N}.{Guid.NewGuid():N}"; - bool result = config.TryGetValue(randomName, out string value); - Assert.False(result); - Assert.Null(value); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_SectionProperty_Exists_ReturnsTrueOutString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - bool result = config.TryGetValue("user", "name", out string value); - Assert.True(result); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_SectionProperty_DoesNotExists_ReturnsFalse() - { - string repoPath = CreateRepository(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string randomSection = Guid.NewGuid().ToString("N"); - string randomProperty = Guid.NewGuid().ToString("N"); - bool result = config.TryGetValue(randomSection, randomProperty, out string value); - Assert.False(result); - Assert.Null(value); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_SectionScopeProperty_Exists_ReturnsTrueOutString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.example.com.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - bool result = config.TryGetValue("user", "example.com", "name", out string value); - Assert.True(result); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_SectionScopeProperty_NullScope_ReturnsTrueOutUnscopedString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - bool result = config.TryGetValue("user", null, "name", out string value); - Assert.True(result); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_TryGetValue_SectionScopeProperty_DoesNotExists_ReturnsFalse() - { - string repoPath = CreateRepository(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - 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); - Assert.False(result); - Assert.Null(value); - } - } - - [Fact] - public void LibGit2Configuration_GetString_Name_Exists_ReturnsString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string value = config.GetValue("user.name"); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_GetString_Name_DoesNotExists_ThrowsException() - { - string repoPath = CreateRepository(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string randomName = $"{Guid.NewGuid():N}.{Guid.NewGuid():N}"; - Assert.Throws(() => config.GetValue(randomName)); - } - } - - [Fact] - public void LibGit2Configuration_GetString_SectionProperty_Exists_ReturnsString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string value = config.GetValue("user", "name"); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_GetString_SectionProperty_DoesNotExists_ThrowsException() - { - string repoPath = CreateRepository(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string randomSection = Guid.NewGuid().ToString("N"); - string randomProperty = Guid.NewGuid().ToString("N"); - Assert.Throws(() => config.GetValue(randomSection, randomProperty)); - } - } - - [Fact] - public void LibGit2Configuration_GetString_SectionScopeProperty_Exists_ReturnsString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.example.com.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string value = config.GetValue("user", "example.com", "name"); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_GetString_SectionScopeProperty_NullScope_ReturnsUnscopedString() - { - string repoPath = CreateRepository(out string workDirPath); - Git(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - string value = config.GetValue("user", null, "name"); - Assert.NotNull(value); - Assert.Equal("john.doe", value); - } - } - - [Fact] - public void LibGit2Configuration_GetString_SectionScopeProperty_DoesNotExists_ThrowsException() - { - string repoPath = CreateRepository(); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - using (var config = git.GetConfiguration(repoPath)) - { - 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)); - } - } - - [Fact] - public void LibGit2Configuration_GetRepositoryPath_ReturnsRepositoryPath() - { - string expectedRepoGitPath = CreateRepository(out string workDirPath) + "/"; - string fileL0Path = Path.Combine(workDirPath, "file.txt"); - string directoryL0Path = Path.Combine(workDirPath, "directory"); - string fileL1Path = Path.Combine(directoryL0Path, "inner-file.txt"); - string directoryL1Path = Path.Combine(directoryL0Path, "sub-directory"); - - var trace = new NullTrace(); - var git = new LibGit2(trace); - - // Create files and directories - Directory.CreateDirectory(directoryL0Path); - Directory.CreateDirectory(directoryL1Path); - File.WriteAllText(fileL0Path, string.Empty); - File.WriteAllText(fileL1Path, string.Empty); - - // Check from L0 file - string fileL0RepoPath = git.GetRepositoryPath(fileL0Path); - AssertPathsEquivalent(expectedRepoGitPath, fileL0RepoPath); - - // Check from L0 directory - string dirL0RepoPath = git.GetRepositoryPath(directoryL0Path); - AssertPathsEquivalent(expectedRepoGitPath, dirL0RepoPath); - - // Check from L1 file - string fileL1RepoPath = git.GetRepositoryPath(fileL1Path); - AssertPathsEquivalent(expectedRepoGitPath, fileL1RepoPath); - - // Check from L1 directory - string dirL1RepoPath = git.GetRepositoryPath(directoryL1Path); - AssertPathsEquivalent(expectedRepoGitPath, dirL1RepoPath); - } - - #region Test helpers - - private static void AssertPathsEquivalent(string expected, string actual) - { - string realExpected = RealPath(expected); - string realActual = RealPath(actual); - - Assert.Equal(realExpected, realActual); - } - - /// - /// Resolve symlinks and canonicalize the path (including "/" -> "\" on Windows) - /// - private static string RealPath(string path) - { - if (PlatformUtils.IsPosix()) - { - bool trailingSlash = path.EndsWith("/"); - - string resolvedPath; - byte[] pathBytes = Encoding.UTF8.GetBytes(path); - unsafe - { - byte* resolvedPtr; - fixed (byte* pathPtr = pathBytes) - { - if ((resolvedPtr = Stdlib.realpath(pathPtr, (byte*) IntPtr.Zero)) == (byte*) IntPtr.Zero) - { - return null; - } - } - - resolvedPath = U8StringConverter.ToManaged(resolvedPtr); - } - - // Preserve the trailing slash if there was one present initially - return trailingSlash ? $"{resolvedPath}/" : resolvedPath; - } - - if (PlatformUtils.IsWindows()) - { - // GetFullPath on Windows already preserves trailing slashes - return Path.GetFullPath(path); - } - - throw new PlatformNotSupportedException(); - } - - private static string CreateBareRepository() - { - string tempDirectory = Path.GetTempPath(); - string repoName = $"repo-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; - var gitDirPath = Path.Combine(tempDirectory, repoName); - - if (Directory.Exists(gitDirPath)) - { - Directory.Delete(gitDirPath); - } - - Directory.CreateDirectory(gitDirPath); - - Git(gitDirPath, gitDirPath, "init --bare").AssertSuccess(); - - return gitDirPath; - } - - private static string CreateRepository() => CreateRepository(out _); - - private static string CreateRepository(out string workDirPath) - { - string tempDirectory = Path.GetTempPath(); - string repoName = $"repo-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; - workDirPath = Path.Combine(tempDirectory, repoName); - string gitDirPath = Path.Combine(workDirPath, ".git"); - - if (Directory.Exists(workDirPath)) - { - Directory.Delete(workDirPath); - } - - Directory.CreateDirectory(workDirPath); - - Git(gitDirPath, workDirPath, "init").AssertSuccess(); - - return gitDirPath; - } - - private static GitResult Git(string repositoryPath, string workingDirectory, string command) - { - var procInfo = new ProcessStartInfo("git", command) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = workingDirectory - }; - - procInfo.Environment["GIT_DIR"] = repositoryPath; - - Process proc = Process.Start(procInfo); - if (proc is null) - { - throw new Exception("Failed to start Git process"); - } - - proc.WaitForExit(); - - var result = new GitResult - { - ExitCode = proc.ExitCode, - StandardOutput = proc.StandardOutput.ReadToEnd(), - StandardError = proc.StandardError.ReadToEnd() - }; - - return result; - } - - private struct GitResult - { - public int ExitCode; - public string StandardOutput; - public string StandardError; - - public void AssertSuccess() - { - Assert.Equal(0, ExitCode); - } - } - - #endregion - } -} diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Linux/SecretServiceCollectionTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Linux/SecretServiceCollectionTests.cs new file mode 100644 index 000000000..2c39545e6 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Linux/SecretServiceCollectionTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using Xunit; +using Microsoft.Git.CredentialManager.Interop.Linux; + +namespace Microsoft.Git.CredentialManager.Tests.Interop.Linux +{ + public class SecretServiceCollectionTests + { + private const string TestNamespace = "git-test"; + + [PlatformFact(Platforms.Linux, Skip = "Cannot run headless")] + public void SecretServiceCollection_ReadWriteDelete() + { + var collection = new SecretServiceCollection(TestNamespace); + + // Create a service that is guaranteed to be unique + string service = $"https://example.com/{Guid.NewGuid():N}"; + const string userName = "john.doe"; + const string password = "letmein123"; + + try + { + // Write + collection.AddOrUpdate(service, userName, password); + + // Read + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, userName); + Assert.Equal(password, outCredential.Password); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + collection.Remove(service, userName); + } + } + + [PlatformFact(Platforms.Linux, Skip = "Cannot run headless")] + public void SecretServiceCollection_Get_NotFound_ReturnsNull() + { + var collection = new SecretServiceCollection(TestNamespace); + + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; + + ICredential credential = collection.Get(service, null); + Assert.Null(credential); + } + + [PlatformFact(Platforms.Linux, Skip = "Cannot run headless")] + public void SecretServiceCollection_Remove_NotFound_ReturnsFalse() + { + var collection = new SecretServiceCollection(TestNamespace); + + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; + + bool result = collection.Remove(service, account: null); + Assert.False(result); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs index 2a253b177..3d6c4558a 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/MacOS/MacOSKeychainTests.cs @@ -8,57 +8,58 @@ namespace Microsoft.Git.CredentialManager.Tests.Interop.MacOS { public class MacOSKeychainTests { - [PlatformFact(Platform.MacOS)] + private const string TestNamespace = "git-test"; + + [PlatformFact(Platforms.MacOS)] public void MacOSKeychain_ReadWriteDelete() { - MacOSKeychain keychain = MacOSKeychain.Open(); + var keychain = new MacOSKeychain(TestNamespace); - // Create a key that is guarenteed to be unique - string key = $"secretkey-{Guid.NewGuid():N}"; - const string userName = "john.doe"; + // Create a service that is guaranteed to be unique + string service = $"https://example.com/{Guid.NewGuid():N}"; + const string account = "john.doe"; const string password = "letmein123"; - var credential = new GitCredential(userName, password); try { // Write - keychain.AddOrUpdate(key, credential); + keychain.AddOrUpdate(service, account, password); // Read - ICredential outCredential = keychain.Get(key); + ICredential outCredential = keychain.Get(service, account); Assert.NotNull(outCredential); - Assert.Equal(credential.UserName, outCredential.UserName); - Assert.Equal(credential.Password, outCredential.Password); + Assert.Equal(account, outCredential.Account); + Assert.Equal(password, outCredential.Password); } finally { // Ensure we clean up after ourselves even in case of 'get' failures - keychain.Remove(key); + keychain.Remove(service, account); } } - [PlatformFact(Platform.MacOS)] - public void MacOSKeychain_Get_KeyNotFound_ReturnsNull() + [PlatformFact(Platforms.MacOS)] + public void MacOSKeychain_Get_NotFound_ReturnsNull() { - MacOSKeychain keychain = MacOSKeychain.Open(); + var keychain = new MacOSKeychain(TestNamespace); - // Unique key; guaranteed not to exist! - string key = Guid.NewGuid().ToString("N"); + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; - ICredential credential = keychain.Get(key); + ICredential credential = keychain.Get(service, account: null); Assert.Null(credential); } - [PlatformFact(Platform.MacOS)] - public void MacOSKeychain_Remove_KeyNotFound_ReturnsFalse() + [PlatformFact(Platforms.MacOS)] + public void MacOSKeychain_Remove_NotFound_ReturnsFalse() { - MacOSKeychain keychain = MacOSKeychain.Open(); + var keychain = new MacOSKeychain(TestNamespace); - // Unique key; guaranteed not to exist! - string key = Guid.NewGuid().ToString("N"); + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; - bool result = keychain.Remove(key); + bool result = keychain.Remove(service, account: null); Assert.False(result); } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs new file mode 100644 index 000000000..e5a79ab7d --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.IO; +using System.Text; +using Xunit; +using Microsoft.Git.CredentialManager.Interop.Posix; +using Microsoft.Git.CredentialManager.Tests.Objects; + +namespace Microsoft.Git.CredentialManager.Tests.Interop.Posix +{ + public class GnuPassCredentialStoreTests + { + private const string TestNamespace = "git-test"; + + [PlatformFact(Platforms.Posix)] + public void GnuPassCredentialStore_ReadWriteDelete() + { + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + string storeRoot = InitializePasswordStore(fs, gpg); + + var collection = new GpgPassCredentialStore(fs, gpg, storeRoot, TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName = "john.doe"; + const string password = "letmein123"; + + string expectedSlug = $"{TestNamespace}/https/example.com/{uniqueGuid}/{userName}.gpg"; + string expectedFilePath = Path.Combine(storeRoot, expectedSlug); + string expectedFileContents = password + Environment.NewLine + + $"service={service}" + Environment.NewLine + + $"account={userName}" + Environment.NewLine; + byte[] expectedFileBytes = Encoding.UTF8.GetBytes(expectedFileContents); + + try + { + // Write + collection.AddOrUpdate(service, userName, password); + + // Read + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, userName); + Assert.Equal(password, outCredential.Password); + Assert.True(fs.Files.ContainsKey(expectedFilePath)); + Assert.Equal(expectedFileBytes, fs.Files[expectedFilePath]); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + collection.Remove(service, userName); + } + } + + [PlatformFact(Platforms.Posix)] + public void GnuPassCredentialStore_Get_NotFound_ReturnsNull() + { + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + string storeRoot = InitializePasswordStore(fs, gpg); + + var collection = new GpgPassCredentialStore(fs, gpg, storeRoot, TestNamespace); + + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; + + ICredential credential = collection.Get(service, null); + Assert.Null(credential); + } + + [PlatformFact(Platforms.Posix)] + public void GnuPassCredentialStore_Remove_NotFound_ReturnsFalse() + { + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + string storeRoot = InitializePasswordStore(fs, gpg); + + var collection = new GpgPassCredentialStore(fs, gpg, storeRoot, TestNamespace); + + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; + + bool result = collection.Remove(service, account: null); + Assert.False(result); + } + + private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) + { + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + string userId = "gcmcore-test@example.com"; + string gpgIdPath = Path.Combine(storePath, ".gpg-id"); + + // Ensure we have a GPG key for use with testing + gpg.GenerateKeys(userId); + + // Init the password store + fs.Directories.Add(storePath); + fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId); + + return storePath; + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsCredentialManagerTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsCredentialManagerTests.cs index 0868a14ca..9e37cef5d 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsCredentialManagerTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsCredentialManagerTests.cs @@ -8,58 +8,232 @@ namespace Microsoft.Git.CredentialManager.Tests.Interop.Windows { public class WindowsCredentialManagerTests { - [PlatformFact(Platform.Windows)] + private const string TestNamespace = "git-test"; + + [PlatformFact(Platforms.Windows)] public void WindowsCredentialManager_ReadWriteDelete() { - WindowsCredentialManager credManager = WindowsCredentialManager.Open(); + var credManager = new WindowsCredentialManager(TestNamespace); - // Create a key that is guarenteed to be unique - string key = $"secretkey-{Guid.NewGuid():N}"; + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; const string userName = "john.doe"; const string password = "letmein123"; - var credential = new GitCredential(userName, password); + + string expectedTargetName = $"{TestNamespace}:https://example.com/{uniqueGuid}"; try { // Write - credManager.AddOrUpdate(key, credential); + credManager.AddOrUpdate(service, userName, password); // Read - ICredential outCredential = credManager.Get(key); + ICredential cred = credManager.Get(service, userName); - Assert.NotNull(outCredential); - Assert.Equal(credential.UserName, outCredential.UserName); - Assert.Equal(credential.Password, outCredential.Password); + // Valdiate + var winCred = cred as WindowsCredential; + Assert.NotNull(winCred); + Assert.Equal(userName, winCred.UserName); + Assert.Equal(password, winCred.Password); + Assert.Equal(service, winCred.Service); + Assert.Equal(expectedTargetName, winCred.TargetName); } finally { // Ensure we clean up after ourselves even in case of 'get' failures - credManager.Remove(key); + credManager.Remove(service, userName); } } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] + public void WindowsCredentialManager_AddOrUpdate_UsernameWithAtCharacter() + { + var credManager = new WindowsCredentialManager(TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName = "john.doe@auth.com"; + const string password = "letmein123"; + + string expectedTargetName = $"{TestNamespace}:https://example.com/{uniqueGuid}"; + + try + { + // Write + credManager.AddOrUpdate(service, userName, password); + + // Read + ICredential cred = credManager.Get(service, userName); + + // Validate + var winCred = cred as WindowsCredential; + Assert.NotNull(winCred); + Assert.Equal(userName, winCred.UserName); + Assert.Equal(password, winCred.Password); + Assert.Equal(service, winCred.Service); + Assert.Equal(expectedTargetName, winCred.TargetName); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + credManager.Remove(service, userName); + } + } + + [PlatformFact(Platforms.Windows)] public void WindowsCredentialManager_Get_KeyNotFound_ReturnsNull() { - WindowsCredentialManager credManager = WindowsCredentialManager.Open(); + var credManager = new WindowsCredentialManager(TestNamespace); - // Unique key; guaranteed not to exist! - string key = Guid.NewGuid().ToString("N"); + // Unique service; guaranteed not to exist! + string service = Guid.NewGuid().ToString("N"); - ICredential credential = credManager.Get(key); + ICredential credential = credManager.Get(service, account: null); Assert.Null(credential); } - [PlatformFact(Platform.Windows)] + [PlatformFact(Platforms.Windows)] public void WindowsCredentialManager_Remove_KeyNotFound_ReturnsFalse() { - WindowsCredentialManager credManager = WindowsCredentialManager.Open(); + var credManager = new WindowsCredentialManager(TestNamespace); - // Unique key; guaranteed not to exist! - string key = Guid.NewGuid().ToString("N"); + // Unique service; guaranteed not to exist! + string service = Guid.NewGuid().ToString("N"); - bool result = credManager.Remove(key); + bool result = credManager.Remove(service, account: null); Assert.False(result); } + + [PlatformFact(Platforms.Windows)] + public void WindowsCredentialManager_AddOrUpdate_TargetNameAlreadyExists_CreatesWithUserInTargetName() + { + var credManager = new WindowsCredentialManager(TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName1 = "john.doe"; + const string userName2 = "jane.doe"; + const string password1 = "letmein123"; + const string password2 = "password123"; + + string expectedTargetName1 = $"{TestNamespace}:https://example.com/{uniqueGuid}"; + string expectedTargetName2 = $"{TestNamespace}:https://{userName2}@example.com/{uniqueGuid}"; + + try + { + // Add first credential + credManager.AddOrUpdate(service, userName1, password1); + + // Add second credential + credManager.AddOrUpdate(service, userName2, password2); + + // Validate first credential properties + ICredential cred1 = credManager.Get(service, userName1); + var winCred1 = cred1 as WindowsCredential; + Assert.NotNull(winCred1); + Assert.Equal(userName1, winCred1.UserName); + Assert.Equal(password1, winCred1.Password); + Assert.Equal(service, winCred1.Service); + Assert.Equal(expectedTargetName1, winCred1.TargetName); + + // Validate second credential properties + ICredential cred2 = credManager.Get(service, userName2); + var winCred2 = cred2 as WindowsCredential; + Assert.NotNull(winCred2); + Assert.Equal(userName2, winCred2.UserName); + Assert.Equal(password2, winCred2.Password); + Assert.Equal(service, winCred2.Service); + Assert.Equal(expectedTargetName2, winCred2.TargetName); + } + finally + { + // Ensure we clean up after ourselves in case of failures + credManager.Remove(service, userName1); + credManager.Remove(service, userName2); + } + } + + [PlatformFact(Platforms.Windows)] + public void WindowsCredentialManager_AddOrUpdate_TargetNameAlreadyExistsAndUserWithAtCharacter_CreatesWithEscapedUserInTargetName() + { + var credManager = new WindowsCredentialManager(TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName1 = "john.doe@auth.com"; + const string userName2 = "jane.doe@auth.com"; + const string escapedUserName2 = "jane.doe_auth.com"; + const string password1 = "letmein123"; + const string password2 = "password123"; + + string expectedTargetName1 = $"{TestNamespace}:https://example.com/{uniqueGuid}"; + string expectedTargetName2 = $"{TestNamespace}:https://{escapedUserName2}@example.com/{uniqueGuid}"; + + try + { + // Add first credential + credManager.AddOrUpdate(service, userName1, password1); + + // Add second credential + credManager.AddOrUpdate(service, userName2, password2); + + // Validate first credential properties + ICredential cred1 = credManager.Get(service, userName1); + var winCred1 = cred1 as WindowsCredential; + Assert.NotNull(winCred1); + Assert.Equal(userName1, winCred1.UserName); + Assert.Equal(password1, winCred1.Password); + Assert.Equal(service, winCred1.Service); + Assert.Equal(expectedTargetName1, winCred1.TargetName); + + // Validate second credential properties + ICredential cred2 = credManager.Get(service, userName2); + var winCred2 = cred2 as WindowsCredential; + Assert.NotNull(winCred2); + Assert.Equal(userName2, winCred2.UserName); + Assert.Equal(password2, winCred2.Password); + Assert.Equal(service, winCred2.Service); + Assert.Equal(expectedTargetName2, winCred2.TargetName); + } + finally + { + // Ensure we clean up after ourselves in case of failures + credManager.Remove(service, userName1); + credManager.Remove(service, userName2); + } + } + + [Theory] + [InlineData("https://example.com", "https://example.com")] + [InlineData("https://example.com/", "https://example.com/")] + [InlineData("https://example.com/@", "https://example.com/@")] + [InlineData("https://example.com/path", "https://example.com/path")] + [InlineData("https://example.com/path@", "https://example.com/path@")] + [InlineData("https://example.com:123/path@", "https://example.com:123/path@")] + [InlineData("https://example.com/path/", "https://example.com/path/")] + [InlineData("https://example.com/path@/", "https://example.com/path@/")] + [InlineData("https://example.com:123/path@/", "https://example.com:123/path@/")] + [InlineData("https://example.com/path/foo", "https://example.com/path/foo")] + [InlineData("https://example.com/path@/foo", "https://example.com/path@/foo")] + [InlineData("https://userinfo@example.com", "https://example.com")] + [InlineData("https://userinfo@example.com/", "https://example.com/")] + [InlineData("https://userinfo@example.com/@", "https://example.com/@")] + [InlineData("https://userinfo@example.com/path", "https://example.com/path")] + [InlineData("https://userinfo@example.com/path@", "https://example.com/path@")] + [InlineData("https://userinfo@example.com/path/", "https://example.com/path/")] + [InlineData("https://userinfo@example.com:123/path/", "https://example.com:123/path/")] + [InlineData("https://userinfo@example.com/path@/", "https://example.com/path@/")] + [InlineData("https://userinfo@example.com:123/path@/", "https://example.com:123/path@/")] + [InlineData("https://userinfo@example.com/path/foo", "https://example.com/path/foo")] + [InlineData("https://userinfo@example.com/path@/foo", "https://example.com/path@/foo")] + public void WindowsCredentialManager_RemoveUriUserInfo(string input, string expected) + { + string actual = WindowsCredentialManager.RemoveUriUserInfo(input); + Assert.Equal(expected, actual); + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/PlaintextCredentialStoreTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/PlaintextCredentialStoreTests.cs new file mode 100644 index 000000000..d0ea3068c --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/PlaintextCredentialStoreTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.IO; +using System.Text; +using Microsoft.Git.CredentialManager.Tests.Objects; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests +{ + public class PlaintextCredentialStoreTests + { + private const string TestNamespace = "git-test"; + private const string StoreRoot = "/tmp/test-store"; + + [Fact] + public void PlaintextCredentialStore_ReadWriteDelete() + { + var fs = new TestFileSystem(); + + var collection = new PlaintextCredentialStore(fs, StoreRoot, TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName = "john.doe"; + const string password = "letmein123"; + + string expectedSlug = Path.Combine( + TestNamespace, + "https", + "example.com", + uniqueGuid, + $"{userName}.credential"); + string expectedFilePath = Path.Combine(StoreRoot, expectedSlug); + string expectedFileContents = password + Environment.NewLine + + $"service={service}" + Environment.NewLine + + $"account={userName}" + Environment.NewLine; + byte[] expectedFileBytes = Encoding.UTF8.GetBytes(expectedFileContents); + + try + { + // Write + collection.AddOrUpdate(service, userName, password); + + // Read + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, userName); + Assert.Equal(password, outCredential.Password); + Assert.True(fs.Files.ContainsKey(expectedFilePath)); + Assert.Equal(expectedFileBytes, fs.Files[expectedFilePath]); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + collection.Remove(service, userName); + } + } + + [Fact] + public void PlaintextCredentialStore_Get_NotFound_ReturnsNull() + { + var fs = new TestFileSystem(); + + var collection = new PlaintextCredentialStore(fs, StoreRoot, TestNamespace); + + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; + + ICredential credential = collection.Get(service, null); + Assert.Null(credential); + } + + [Fact] + public void PlaintextCredentialStore_Remove_NotFound_ReturnsFalse() + { + var fs = new TestFileSystem(); + + var collection = new PlaintextCredentialStore(fs, StoreRoot, TestNamespace); + + // Unique service; guaranteed not to exist! + string service = $"https://example.com/{Guid.NewGuid():N}"; + + bool result = collection.Remove(service, account: null); + Assert.False(result); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs index 659b1ca0f..dd8cef277 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/SettingsTests.cs @@ -322,14 +322,13 @@ public void Settings_IsSecretTracingEnabled_EnvarFalsey_ReturnsFalse() [Fact] public void Settings_ProxyConfiguration_Unset_ReturnsNull() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); var envars = new TestEnvironment(); var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -341,7 +340,6 @@ public void Settings_ProxyConfiguration_Unset_ReturnsNull() [Fact] public void Settings_ProxyConfiguration_GcmHttpConfig_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string section = Constants.GitConfiguration.Credential.SectionName; const string property = Constants.GitConfiguration.Credential.HttpProxy; @@ -353,7 +351,7 @@ public void Settings_ProxyConfiguration_GcmHttpConfig_ReturnsValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = expectedValue.ToString(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -366,7 +364,6 @@ public void Settings_ProxyConfiguration_GcmHttpConfig_ReturnsValue() [Fact] public void Settings_ProxyConfiguration_GcmHttpsConfig_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "https://example.com/foo.git"; const string section = Constants.GitConfiguration.Credential.SectionName; const string property = Constants.GitConfiguration.Credential.HttpsProxy; @@ -378,7 +375,7 @@ public void Settings_ProxyConfiguration_GcmHttpsConfig_ReturnsValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = expectedValue.ToString(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -391,7 +388,6 @@ public void Settings_ProxyConfiguration_GcmHttpsConfig_ReturnsValue() [Fact] public void Settings_ProxyConfiguration_GitHttpConfig_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string section = Constants.GitConfiguration.Http.SectionName; const string property = Constants.GitConfiguration.Http.Proxy; @@ -403,7 +399,7 @@ public void Settings_ProxyConfiguration_GitHttpConfig_ReturnsValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = expectedValue.ToString(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -416,7 +412,6 @@ public void Settings_ProxyConfiguration_GitHttpConfig_ReturnsValue() [Fact] public void Settings_ProxyConfiguration_CurlHttpEnvar_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -428,7 +423,7 @@ public void Settings_ProxyConfiguration_CurlHttpEnvar_ReturnsValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -441,7 +436,6 @@ public void Settings_ProxyConfiguration_CurlHttpEnvar_ReturnsValue() [Fact] public void Settings_ProxyConfiguration_CurlHttpsEnvar_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "https://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -453,7 +447,7 @@ public void Settings_ProxyConfiguration_CurlHttpsEnvar_ReturnsValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -466,7 +460,6 @@ public void Settings_ProxyConfiguration_CurlHttpsEnvar_ReturnsValue() [Fact] public void Settings_TryGetProxy_CurlAllEnvar_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "https://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -478,7 +471,7 @@ public void Settings_TryGetProxy_CurlAllEnvar_ReturnsValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -491,7 +484,6 @@ public void Settings_TryGetProxy_CurlAllEnvar_ReturnsValue() [Fact] public void Settings_ProxyConfiguration_LegacyGcmEnvar_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -503,7 +495,7 @@ public void Settings_ProxyConfiguration_LegacyGcmEnvar_ReturnsValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -528,7 +520,6 @@ public void Settings_ProxyConfiguration_Precedence_ReturnsValue() // 4. GCM proxy environment variable (deprecated) // GCM_HTTP_PROXY - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -542,7 +533,7 @@ public void Settings_ProxyConfiguration_Precedence_ReturnsValue() void RunTest(Uri expectedValue) { - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -567,14 +558,13 @@ void RunTest(Uri expectedValue) [Fact] public void Settings_ProviderOverride_Unset_ReturnsNull() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); var envars = new TestEnvironment(); var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -586,7 +576,6 @@ public void Settings_ProviderOverride_Unset_ReturnsNull() [Fact] public void Settings_ProviderOverride_EnvarSet_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -598,7 +587,7 @@ public void Settings_ProviderOverride_EnvarSet_ReturnsValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -610,7 +599,6 @@ public void Settings_ProviderOverride_EnvarSet_ReturnsValue() [Fact] public void Settings_ProviderOverride_ConfigSet_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string section = Constants.GitConfiguration.Credential.SectionName; const string property = Constants.GitConfiguration.Credential.Provider; @@ -622,7 +610,7 @@ public void Settings_ProviderOverride_ConfigSet_ReturnsValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = expectedValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -634,7 +622,6 @@ public void Settings_ProviderOverride_ConfigSet_ReturnsValue() [Fact] public void Settings_ProviderOverride_EnvarAndConfigSet_ReturnsEnvarValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string section = Constants.GitConfiguration.Credential.SectionName; const string property = Constants.GitConfiguration.Credential.Provider; @@ -650,7 +637,7 @@ public void Settings_ProviderOverride_EnvarAndConfigSet_ReturnsEnvarValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = otherValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -662,14 +649,13 @@ public void Settings_ProviderOverride_EnvarAndConfigSet_ReturnsEnvarValue() [Fact] public void Settings_LegacyAuthorityOverride_Unset_ReturnsNull() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); var envars = new TestEnvironment(); var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -681,7 +667,6 @@ public void Settings_LegacyAuthorityOverride_Unset_ReturnsNull() [Fact] public void Settings_LegacyAuthorityOverride_EnvarSet_ReturnsValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; var remoteUri = new Uri(remoteUrl); @@ -693,7 +678,7 @@ public void Settings_LegacyAuthorityOverride_EnvarSet_ReturnsValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -705,7 +690,6 @@ public void Settings_LegacyAuthorityOverride_EnvarSet_ReturnsValue() [Fact] public void Settings_LegacyAuthorityOverride_ConfigSet_ReturnsTrueOutValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string section = Constants.GitConfiguration.Credential.SectionName; const string property = Constants.GitConfiguration.Credential.Authority; @@ -717,7 +701,7 @@ public void Settings_LegacyAuthorityOverride_ConfigSet_ReturnsTrueOutValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = expectedValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -729,7 +713,6 @@ public void Settings_LegacyAuthorityOverride_ConfigSet_ReturnsTrueOutValue() [Fact] public void Settings_LegacyAuthorityOverride_EnvarAndConfigSet_ReturnsEnvarValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string section = Constants.GitConfiguration.Credential.SectionName; const string property = Constants.GitConfiguration.Credential.Authority; @@ -745,7 +728,7 @@ public void Settings_LegacyAuthorityOverride_EnvarAndConfigSet_ReturnsEnvarValue var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = otherValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -757,7 +740,6 @@ public void Settings_LegacyAuthorityOverride_EnvarAndConfigSet_ReturnsEnvarValue [Fact] public void Settings_TryGetSetting_EnvarSet_ReturnsTrueOutValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string envarName = "GCM_TESTVAR"; const string section = "gcmtest"; @@ -772,7 +754,7 @@ public void Settings_TryGetSetting_EnvarSet_ReturnsTrueOutValue() }; var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -785,7 +767,6 @@ public void Settings_TryGetSetting_EnvarSet_ReturnsTrueOutValue() [Fact] public void Settings_TryGetSetting_EnvarUnset_ReturnsFalse() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string envarName = "GCM_TESTVAR"; const string section = "gcmtest"; @@ -795,7 +776,7 @@ public void Settings_TryGetSetting_EnvarUnset_ReturnsFalse() var envars = new TestEnvironment(); var git = new TestGit(); - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -808,7 +789,6 @@ public void Settings_TryGetSetting_EnvarUnset_ReturnsFalse() [Fact] public void Settings_TryGetSetting_GlobalConfig_ReturnsTrueAndValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string envarName = "GCM_TESTVAR"; const string section = "gcmtest"; @@ -821,7 +801,7 @@ public void Settings_TryGetSetting_GlobalConfig_ReturnsTrueAndValue() var git = new TestGit(); git.GlobalConfiguration[$"{section}.{property}"] = expectedValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -834,7 +814,6 @@ public void Settings_TryGetSetting_GlobalConfig_ReturnsTrueAndValue() [Fact] public void Settings_TryGetSetting_RepoConfig_ReturnsTrueAndValue() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string envarName = "GCM_TESTVAR"; const string section = "gcmtest"; @@ -845,10 +824,9 @@ public void Settings_TryGetSetting_RepoConfig_ReturnsTrueAndValue() var envars = new TestEnvironment(); var git = new TestGit(); - var repo = git.AddRepository(repositoryPath); - repo.Configuration[$"{section}.{property}"] = expectedValue; + git.LocalConfiguration[$"{section}.{property}"] = expectedValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -861,7 +839,6 @@ public void Settings_TryGetSetting_RepoConfig_ReturnsTrueAndValue() [Fact] public void Settings_TryGetSetting_ScopedConfig() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo/bar/bazz.git"; const string scope1 = "example.com"; const string scope2 = "example.com/foo/bar"; @@ -875,11 +852,10 @@ public void Settings_TryGetSetting_ScopedConfig() var envars = new TestEnvironment(); var git = new TestGit(); - var repo = git.AddRepository(repositoryPath); - repo.Configuration[$"{section}.{scope1}.{property}"] = otherValue; - repo.Configuration[$"{section}.{scope2}.{property}"] = expectedValue; + git.LocalConfiguration[$"{section}.{scope1}.{property}"] = otherValue; + git.LocalConfiguration[$"{section}.{scope2}.{property}"] = expectedValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -892,7 +868,6 @@ public void Settings_TryGetSetting_ScopedConfig() [Fact] public void Settings_TryGetSetting_EnvarAndConfig_EnvarTakesPrecedence() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string envarName = "GCM_TESTVAR"; const string section = "gcmtest"; @@ -907,10 +882,9 @@ public void Settings_TryGetSetting_EnvarAndConfig_EnvarTakesPrecedence() Variables = {[envarName] = expectedValue} }; var git = new TestGit(); - var repo = git.AddRepository(repositoryPath); - repo.Configuration[$"{section}.{property}"] = otherValue; + git.LocalConfiguration[$"{section}.{property}"] = otherValue; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; @@ -923,7 +897,6 @@ public void Settings_TryGetSetting_EnvarAndConfig_EnvarTakesPrecedence() [Fact] public void Settings_GetSettingValues_EnvarAndMultipleConfig_ReturnsAllWithCorrectPrecedence() { - const string repositoryPath = "/tmp/repos/foo/.git"; const string remoteUrl = "http://example.com/foo.git"; const string scope1 = "http://example.com"; const string scope2 = "example.com"; @@ -944,12 +917,11 @@ public void Settings_GetSettingValues_EnvarAndMultipleConfig_ReturnsAllWithCorre Variables = {[envarName] = value1} }; var git = new TestGit(); - var repo = git.AddRepository(repositoryPath); - repo.Configuration[$"{section}.{scope1}.{property}"] = value2; - repo.Configuration[$"{section}.{scope2}.{property}"] = value3; - repo.Configuration[$"{section}.{property}"] = value4; + git.LocalConfiguration[$"{section}.{scope1}.{property}"] = value2; + git.LocalConfiguration[$"{section}.{scope2}.{property}"] = value3; + git.LocalConfiguration[$"{section}.{property}"] = value4; - var settings = new Settings(envars, git, repositoryPath) + var settings = new Settings(envars, git) { RemoteUri = remoteUri }; diff --git a/src/shared/Microsoft.Git.CredentialManager/Application.cs b/src/shared/Microsoft.Git.CredentialManager/Application.cs index c67ad2298..37c7c4a3f 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Application.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Application.cs @@ -143,7 +143,7 @@ protected bool WriteException(Exception ex) Task IConfigurableComponent.ConfigureAsync( IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGitConfiguration configuration, GitConfigurationLevel configurationLevel) + IGit git, GitConfigurationLevel configurationLevel) { // 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. @@ -164,59 +164,58 @@ Task IConfigurableComponent.ConfigureAsync( string helperKey = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; string gitConfigAppName = GetGitConfigAppName(); - using (IGitConfiguration targetConfig = configuration.GetFilteredConfiguration(configurationLevel)) + 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 { - /* - * 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.GetMultivarValue(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 - { - Context.Trace.WriteLine("Updating Git credential helper configuration..."); + Context.Trace.WriteLine("Updating Git credential helper configuration..."); - // Clear any existing entries in the configuration. - targetConfig.DeleteMultivarEntry(helperKey, Constants.RegexPatterns.Any); + // Clear any existing entries in the configuration. + targetConfig.UnsetAll(helperKey, Constants.RegexPatterns.Any); - // 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.SetMultivarValue(helperKey, Constants.RegexPatterns.None, gitConfigAppName); - } - else - { - Context.Trace.WriteLine("Credential helper configuration is already set correctly."); - } + // 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); } + else + { + Context.Trace.WriteLine("Credential helper configuration is already set correctly."); + } + return Task.CompletedTask; } Task IConfigurableComponent.UnconfigureAsync( IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGitConfiguration configuration, GitConfigurationLevel configurationLevel) + IGit git, GitConfigurationLevel configurationLevel) { string helperKey = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; string gitConfigAppName = GetGitConfigAppName(); - using (IGitConfiguration targetConfig = configuration.GetFilteredConfiguration(configurationLevel)) - { - Context.Trace.WriteLine("Removing Git credential helper configuration..."); + IGitConfiguration targetConfig = git.GetConfiguration(configurationLevel); - // Clear any blank 'reset' entries - targetConfig.DeleteMultivarEntry(helperKey, Constants.RegexPatterns.Empty); + Context.Trace.WriteLine("Removing Git credential helper configuration..."); - // Clear GCM executable entries - targetConfig.DeleteMultivarEntry(helperKey, Regex.Escape(gitConfigAppName)); - } + // Clear any blank 'reset' entries + targetConfig.UnsetAll(helperKey, Constants.RegexPatterns.Empty); + + // 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. diff --git a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs index 20fff2f9f..c6d1ef136 100644 --- a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs +++ b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; -using Microsoft.Git.CredentialManager.Interop; +using Microsoft.Git.CredentialManager.Interop.Linux; using Microsoft.Git.CredentialManager.Interop.MacOS; using Microsoft.Git.CredentialManager.Interop.Posix; using Microsoft.Git.CredentialManager.Interop.Windows; @@ -78,37 +78,62 @@ public CommandContext() { Streams = new StandardStreams(); Trace = new Trace(); - Git = new LibGit2(Trace); if (PlatformUtils.IsWindows()) { - FileSystem = new WindowsFileSystem(); - Environment = new WindowsEnvironment(FileSystem); - Terminal = new WindowsTerminal(Trace); - SessionManager = new WindowsSessionManager(); - CredentialStore = WindowsCredentialManager.Open(); - SystemPrompts = new WindowsSystemPrompts(); + FileSystem = new WindowsFileSystem(); + SessionManager = new WindowsSessionManager(); + SystemPrompts = new WindowsSystemPrompts(); + Environment = new WindowsEnvironment(FileSystem); + Terminal = new WindowsTerminal(Trace); + Git = new GitProcess( + Trace, + Environment.LocateExecutable("git.exe"), + FileSystem.GetCurrentDirectory() + ); + Settings = new Settings(Environment, Git); + CredentialStore = new WindowsCredentialManager(Settings.CredentialNamespace); } - else if (PlatformUtils.IsPosix()) + else if (PlatformUtils.IsMacOS()) { - if (PlatformUtils.IsMacOS()) - { - FileSystem = new MacOSFileSystem(); - SessionManager = new MacOSSessionManager(); - CredentialStore = MacOSKeychain.Open(); - SystemPrompts = new MacOSSystemPrompts(); - } - else if (PlatformUtils.IsLinux()) - { - throw new NotImplementedException(); - } - - Environment = new PosixEnvironment(FileSystem); - Terminal = new PosixTerminal(Trace); + FileSystem = new MacOSFileSystem(); + SessionManager = new MacOSSessionManager(); + SystemPrompts = new MacOSSystemPrompts(); + Environment = new PosixEnvironment(FileSystem); + Terminal = new PosixTerminal(Trace); + Git = new GitProcess( + Trace, + Environment.LocateExecutable("git"), + FileSystem.GetCurrentDirectory() + ); + Settings = new Settings(Environment, Git); + CredentialStore = new MacOSKeychain(Settings.CredentialNamespace); + } + else if (PlatformUtils.IsLinux()) + { + FileSystem = new LinuxFileSystem(); + // TODO: support more than just 'Posix' or X11 + SessionManager = new PosixSessionManager(); + SystemPrompts = new LinuxSystemPrompts(); + Environment = new PosixEnvironment(FileSystem); + Terminal = new PosixTerminal(Trace); + Git = new GitProcess( + Trace, + Environment.LocateExecutable("git"), + FileSystem.GetCurrentDirectory() + ); + Settings = new Settings(Environment, Git); + IGpg gpg = new Gpg( + Environment.LocateExecutable("gpg"), + SessionManager + ); + CredentialStore = new LinuxCredentialStore(FileSystem, Settings, SessionManager, gpg, Environment); + } + else + { + throw new PlatformNotSupportedException(); } - string repoPath = Git.GetRepositoryPath(FileSystem.GetCurrentDirectory()); - Settings = new Settings(Environment, Git, repoPath); HttpClientFactory = new HttpClientFactory(Trace, Settings, Streams); // Set the parent window handle/ID @@ -146,7 +171,6 @@ public CommandContext() protected override void ReleaseManagedResources() { Settings?.Dispose(); - Git?.Dispose(); Trace?.Dispose(); base.ReleaseManagedResources(); diff --git a/src/shared/Microsoft.Git.CredentialManager/Commands/GetCommand.cs b/src/shared/Microsoft.Git.CredentialManager/Commands/GetCommand.cs index bfebaa03d..94b236cd4 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Commands/GetCommand.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Commands/GetCommand.cs @@ -36,7 +36,7 @@ protected override async Task ExecuteInternalAsync(ICommandContext context, Inpu } // Return the credential to Git - output["username"] = credential.UserName; + output["username"] = credential.Account; output["password"] = credential.Password; // Write the values to standard out diff --git a/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs b/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs index b39fd5bef..7c9d3cc60 100644 --- a/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs +++ b/src/shared/Microsoft.Git.CredentialManager/ConfigurationService.cs @@ -37,20 +37,20 @@ public interface IConfigurableComponent /// /// Environment variables. /// Environment variable target to update. - /// Git configuration. + /// Git object. /// Git configuration level to update. Task ConfigureAsync(IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGitConfiguration configuration, GitConfigurationLevel configurationLevel); + IGit git, GitConfigurationLevel configurationLevel); /// /// Remove changes to the environment and Git configuration previously made with . /// /// Environment variables. /// Environment variable target to update. - /// Git configuration. + /// Git object. /// Git configuration level to update. Task UnconfigureAsync(IEnvironment environment, EnvironmentVariableTarget environmentTarget, - IGitConfiguration configuration, GitConfigurationLevel configurationLevel); + IGit git, GitConfigurationLevel configurationLevel); } public interface IConfigurationService @@ -117,22 +117,19 @@ private async Task RunAsync(ConfigurationTarget target, bool configure) throw new ArgumentOutOfRangeException(nameof(target)); } - using (IGitConfiguration config = _context.Git.GetConfiguration()) + foreach (IConfigurableComponent component in _components) { - foreach (IConfigurableComponent component in _components) + if (configure) { - if (configure) - { - _context.Trace.WriteLine($"Configuring component '{component.Name}'..."); - _context.Streams.Error.WriteLine($"Configuring component '{component.Name}'..."); - await component.ConfigureAsync(_context.Environment, envTarget, config, configLevel); - } - else - { - _context.Trace.WriteLine($"Unconfiguring component '{component.Name}'..."); - _context.Streams.Error.WriteLine($"Unconfiguring component '{component.Name}'..."); - await component.UnconfigureAsync(_context.Environment, envTarget, config, configLevel); - } + _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); } } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Constants.cs b/src/shared/Microsoft.Git.CredentialManager/Constants.cs index ba9ec41f0..9ac42baff 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Constants.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Constants.cs @@ -8,12 +8,14 @@ namespace Microsoft.Git.CredentialManager public static class Constants { public const string PersonalAccessTokenUserName = "PersonalAccessToken"; - public const string OAuthTokenUserName = "OAuthToken"; public const string DefaultMsAuthHelper = "Microsoft.Authentication.Helper"; + public const string DefaultCredentialNamespace = "git"; public const string ProviderIdAuto = "auto"; public const string AuthorityIdAuto = "auto"; + public const string GcmConfigDirectoryName = ".gcm"; + public static class RegexPatterns { /// @@ -34,22 +36,25 @@ public static class RegexPatterns public static class EnvironmentVariables { - public const string GcmTrace = "GCM_TRACE"; - public const string GcmTraceSecrets = "GCM_TRACE_SECRETS"; - public const string GcmTraceMsAuth = "GCM_TRACE_MSAUTH"; - public const string GcmDebug = "GCM_DEBUG"; - public const string GcmProvider = "GCM_PROVIDER"; - public const string GcmAuthority = "GCM_AUTHORITY"; - public const string GitTerminalPrompts = "GIT_TERMINAL_PROMPT"; - public const string GcmAllowWia = "GCM_ALLOW_WINDOWSAUTH"; - public const string CurlAllProxy = "ALL_PROXY"; - public const string CurlHttpProxy = "HTTP_PROXY"; - public const string CurlHttpsProxy = "HTTPS_PROXY"; - public const string GcmHttpProxy = "GCM_HTTP_PROXY"; - public const string GitSslNoVerify = "GIT_SSL_NO_VERIFY"; - public const string GcmInteractive = "GCM_INTERACTIVE"; - public const string GcmParentWindow = "GCM_MODAL_PARENTHWND"; - public const string MsAuthHelper = "GCM_MSAUTH_HELPER"; + public const string GcmTrace = "GCM_TRACE"; + public const string GcmTraceSecrets = "GCM_TRACE_SECRETS"; + public const string GcmTraceMsAuth = "GCM_TRACE_MSAUTH"; + public const string GcmDebug = "GCM_DEBUG"; + public const string GcmProvider = "GCM_PROVIDER"; + public const string GcmAuthority = "GCM_AUTHORITY"; + public const string GitTerminalPrompts = "GIT_TERMINAL_PROMPT"; + public const string GcmAllowWia = "GCM_ALLOW_WINDOWSAUTH"; + public const string CurlAllProxy = "ALL_PROXY"; + public const string CurlHttpProxy = "HTTP_PROXY"; + public const string CurlHttpsProxy = "HTTPS_PROXY"; + public const string GcmHttpProxy = "GCM_HTTP_PROXY"; + public const string GitSslNoVerify = "GIT_SSL_NO_VERIFY"; + public const string GcmInteractive = "GCM_INTERACTIVE"; + public const string GcmParentWindow = "GCM_MODAL_PARENTHWND"; + public const string MsAuthHelper = "GCM_MSAUTH_HELPER"; + public const string GcmCredNamespace = "GCM_NAMESPACE"; + public const string GcmCredentialStore = "GCM_CREDENTIAL_STORE"; + public const string GcmPlaintextStorePath = "GCM_PLAINTEXT_STORE_PATH"; } public static class Http @@ -76,6 +81,9 @@ public static class Credential public const string UseHttpPath = "useHttpPath"; public const string Interactive = "interactive"; public const string MsAuthHelper = "msauthHelper"; + public const string CredNamespace = "namespace"; + public const string CredentialStore = "credentialStore"; + public const string PlaintextStorePath = "plaintextStorePath"; } public static class Http @@ -92,6 +100,7 @@ public static class HelpUrls public const string GcmAuthorityDeprecated = "https://aka.ms/gcmcore-authority"; public const string GcmHttpProxyGuide = "https://aka.ms/gcmcore-httpproxy"; public const string GcmTlsVerification = "https://aka.ms/gcmcore-tlsverify"; + public const string GcmLinuxCredStores = "https://aka.ms/gcmcore-linuxcredstores"; } private static string _gcmVersion; diff --git a/src/shared/Microsoft.Git.CredentialManager/Credential.cs b/src/shared/Microsoft.Git.CredentialManager/Credential.cs index 0c59f66b9..d311a6ba0 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Credential.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Credential.cs @@ -4,14 +4,14 @@ namespace Microsoft.Git.CredentialManager { /// - /// Represents a simple credential; user name and password pair. + /// Represents a credential. /// public interface ICredential { /// - /// User name. + /// Account associated with this credential. /// - string UserName { get; } + string Account { get; } /// /// Password. @@ -26,11 +26,11 @@ public class GitCredential : ICredential { public GitCredential(string userName, string password) { - UserName = userName; + Account = userName; Password = password; } - public string UserName { get; } + public string Account { get; } public string Password { get; } } diff --git a/src/shared/Microsoft.Git.CredentialManager/EnvironmentBase.cs b/src/shared/Microsoft.Git.CredentialManager/EnvironmentBase.cs index 9f02446df..47ea8763f 100644 --- a/src/shared/Microsoft.Git.CredentialManager/EnvironmentBase.cs +++ b/src/shared/Microsoft.Git.CredentialManager/EnvironmentBase.cs @@ -36,6 +36,13 @@ public interface IEnvironment /// Path to directory to remove from the path. /// The level of the path environment variable that should be modified. void RemoveDirectoryFromPath(string directoryPath, EnvironmentVariableTarget target); + + /// + /// Locate an executable on the current PATH. + /// + /// Executable program name. + /// List of all instances of the found executable program, in order of most specific to least. + string LocateExecutable(string program); } public abstract class EnvironmentBase : IEnvironment @@ -67,5 +74,7 @@ public bool IsDirectoryOnPath(string directoryPath) public abstract void RemoveDirectoryFromPath(string directoryPath, EnvironmentVariableTarget target); protected abstract string[] SplitPathVariable(string value); + + public abstract string LocateExecutable(string program); } } diff --git a/src/shared/Microsoft.Git.CredentialManager/FileCredential.cs b/src/shared/Microsoft.Git.CredentialManager/FileCredential.cs new file mode 100644 index 000000000..faea5e65e --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/FileCredential.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +namespace Microsoft.Git.CredentialManager +{ + public class FileCredential : ICredential + { + public FileCredential(string fullPath, string service, string account, string password) + { + FullPath = fullPath; + Service = service; + Account = account; + Password = password; + } + + public string FullPath { get; } + + public string Service { get; } + + public string Account { get; } + + public string Password { get; } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/FileSystem.cs b/src/shared/Microsoft.Git.CredentialManager/FileSystem.cs index bf3882887..47556ca52 100644 --- a/src/shared/Microsoft.Git.CredentialManager/FileSystem.cs +++ b/src/shared/Microsoft.Git.CredentialManager/FileSystem.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System.Collections.Generic; using System.IO; namespace Microsoft.Git.CredentialManager @@ -46,6 +47,34 @@ public interface IFileSystem /// File share settings. /// Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare); + + /// + /// Creates all directories and subdirectories in the specified path unless they already exist. + /// + /// The directory to create. + void CreateDirectory(string path); + + /// + /// Deletes the specified file. + /// + /// Name of the file to be deleted. + void DeleteFile(string path); + + /// + /// Returns an enumerable collection of full file names that match a search pattern in a specified path. + /// + /// The relative or absolute path to the directory to search. + /// + /// The search string to match against the names of files in path. + /// This parameter can contain a combination of valid literal path and wildcard (* and ?) characters, + /// but it doesn't support regular expressions. + /// + /// + /// An enumerable collection of the full names (including paths) for the files in the directory + /// specified by path and that match the specified search pattern. + /// + IEnumerable EnumerateFiles(string path, string searchPattern); + } /// @@ -63,5 +92,11 @@ public abstract class FileSystem : IFileSystem public Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare) => File.Open(path, fileMode, fileAccess, fileShare); + + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + + public void DeleteFile(string path) => File.Delete(path); + + public IEnumerable EnumerateFiles(string path, string searchPattern) => Directory.EnumerateFiles(path, searchPattern); } } diff --git a/src/shared/Microsoft.Git.CredentialManager/GenericHostProvider.cs b/src/shared/Microsoft.Git.CredentialManager/GenericHostProvider.cs index 481d192d3..421e7cc80 100644 --- a/src/shared/Microsoft.Git.CredentialManager/GenericHostProvider.cs +++ b/src/shared/Microsoft.Git.CredentialManager/GenericHostProvider.cs @@ -46,16 +46,11 @@ public override bool IsSupported(InputArguments input) StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")); } - public override string GetCredentialKey(InputArguments input) - { - return $"git:{GetUriFromInput(input).AbsoluteUri}"; - } - public override async Task GenerateCredentialAsync(InputArguments input) { ThrowIfDisposed(); - Uri uri = GetUriFromInput(input); + Uri uri = input.GetRemoteUri(); // Determine the if the host supports Windows Integration Authentication (WIA) if (IsWindowsAuthAllowed) @@ -89,7 +84,7 @@ public override async Task GenerateCredentialAsync(InputArguments i } Context.Trace.WriteLine("Prompting for basic credentials..."); - return _basicAuth.GetCredentials(uri.AbsoluteUri, uri.UserInfo); + return _basicAuth.GetCredentials(uri.AbsoluteUri, input.UserName); } /// @@ -124,20 +119,5 @@ protected override void ReleaseManagedResources() } #endregion - - #region Helpers - - private static Uri GetUriFromInput(InputArguments input) - { - return new UriBuilder - { - Scheme = input.Protocol, - UserName = input.UserName, - Host = input.Host, - Path = input.Path - }.Uri; - } - - #endregion } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Git.cs b/src/shared/Microsoft.Git.CredentialManager/Git.cs new file mode 100644 index 000000000..c541d45b2 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Git.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System.Diagnostics; + +namespace Microsoft.Git.CredentialManager +{ + public interface IGit + { + /// + /// Get the configuration for the specific configuration level. + /// + /// Configuration level filter. + /// Git configuration. + IGitConfiguration GetConfiguration(GitConfigurationLevel level); + } + + public class GitProcess : IGit + { + private readonly ITrace _trace; + private readonly string _gitPath; + private readonly string _workingDirectory; + + public GitProcess(ITrace trace, string gitPath, string workingDirectory = null) + { + EnsureArgument.NotNull(trace, nameof(trace)); + EnsureArgument.NotNullOrWhiteSpace(gitPath, nameof(gitPath)); + + _trace = trace; + _gitPath = gitPath; + _workingDirectory = workingDirectory; + } + + public IGitConfiguration GetConfiguration(GitConfigurationLevel level) + { + return new GitProcessConfiguration(_trace, this); + } + + public Process CreateProcess(string args) + { + var psi = new ProcessStartInfo(_gitPath, args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = _workingDirectory + }; + + return new Process {StartInfo = psi}; + } + } + + public static class GitExtensions + { + /// + /// Get the configuration. + /// + /// Git object. + /// Git configuration. + public static IGitConfiguration GetConfiguration(this IGit git) => git.GetConfiguration(GitConfigurationLevel.All); + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs b/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs new file mode 100644 index 000000000..13bfbc1ba --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.Git.CredentialManager +{ + /// + /// Invoked for each Git configuration entry during an enumeration (). + /// + /// Name of the current configuration entry. + /// Value of the current configuration entry. + /// True to continue enumeration, false to stop enumeration. + public delegate bool GitConfigurationEnumerationCallback(string name, string value); + + public enum GitConfigurationLevel + { + All, + ProgramData, + System, + Xdg, + Global, + Local, + } + + public interface IGitConfiguration + { + /// + /// Enumerate all configuration entries invoking the specified callback for each entry. + /// + /// Callback to invoke for each configuration entry. + void Enumerate(GitConfigurationEnumerationCallback cb); + + /// + /// Try and get the value of a configuration entry as a string. + /// + /// Configuration entry name. + /// Configuration entry value. + /// True if the value was found, false otherwise. + bool TryGetValue(string name, out string value); + + /// + /// Set the value of a configuration entry. + /// + /// Configuration entry name. + /// Configuration entry value. + void SetValue(string name, string value); + + /// + /// Deletes a configuration entry from the highest level. + /// + /// Configuration entry name. + void Unset(string name); + + /// + /// Get all values of a multivar configuration entry. + /// + /// Configuration entry name regular expression. + /// Regular expression to filter which variables we're interested in. Use null to indicate all. + /// All values of the multivar configuration entry. + IEnumerable GetRegex(string nameRegex, string valueRegex); + + /// + /// Set a multivar configuration entry value. + /// + /// Configuration entry name regular expression. + /// Regular expression to indicate which values to replace. + /// Configuration entry value. + /// If the regular expression does not match any existing entry, a new entry is created. + void ReplaceAll(string nameRegex, string valueRegex, string value); + + /// + /// Deletes one or several entries from a multivar. + /// + /// Configuration entry name. + /// Regular expression to indicate which values to delete. + void UnsetAll(string name, string valueRegex); + } + + public class GitProcessConfiguration : IGitConfiguration + { + private readonly ITrace _trace; + private readonly GitProcess _git; + private readonly GitConfigurationLevel? _filterLevel; + + internal GitProcessConfiguration(ITrace trace, GitProcess git, GitConfigurationLevel filterLevel = GitConfigurationLevel.All) + { + EnsureArgument.NotNull(trace, nameof(trace)); + EnsureArgument.NotNull(git, nameof(git)); + + _trace = trace; + _git = git; + _filterLevel = filterLevel; + } + + public void Enumerate(GitConfigurationEnumerationCallback cb) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config --null {level} --list")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + break; + default: + throw new Exception( + $"Failed to enumerate all Git configuration entries. Exit code '{git.ExitCode}' (level={_filterLevel})"); + } + + // TODO: don't read in all the data at once; stream it + string data = git.StandardOutput.ReadToEnd(); + + IEnumerable entries = data.Split('\0').Where(x => !string.IsNullOrWhiteSpace(x)); + foreach (string entry in entries) + { + string[] kvp = entry.Split(new[]{'\n'}, count: 2); + + if (kvp.Length == 2 && !cb(kvp[0], kvp[1])) + { + break; + } + } + } + } + + public bool TryGetValue(string name, out string value) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config {level} {name}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + break; + case 1: // Not found + value = null; + return false; + default: // Error + _trace.WriteLine( + $"Failed to read Git configuration entry '{name}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + value = null; + return false; + } + + string data = git.StandardOutput.ReadToEnd().TrimEnd('\n'); + + if (string.IsNullOrWhiteSpace(data)) + { + value = null; + return false; + } + + value = data; + return true; + } + } + + public void SetValue(string name, string value) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config {level} {name} \"{value}\"")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + break; + default: + throw new Exception( + $"Failed to set Git configuration entry '{name}' to '{value}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + } + } + } + + public void Unset(string name) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config {level} --unset {name}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + break; + default: + throw new Exception( + $"Failed to unset Git configuration entry '{name}'. Exit code '{git.ExitCode}' (level={_filterLevel})"); + } + } + } + + public IEnumerable GetRegex(string nameRegex, string valueRegex) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config --null {level} --get-regex {nameRegex} {valueRegex}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + 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})"); + } + + // TODO: don't read in all the data at once; stream it + string data = git.StandardOutput.ReadToEnd(); + + string[] entries = data.Split('\0'); + foreach (string entry in entries) + { + string[] kvp = entry.Split(new[]{'\n'}, count: 2); + + if (kvp.Length == 2) { + yield return kvp[1]; + } + } + } + } + + public void ReplaceAll(string name, string valueRegex, string value) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config {level} --replace-all {name} {value} {valueRegex}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + 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})"); + } + } + } + + public void UnsetAll(string name, string valueRegex) + { + string level = GetLevelFilterArg(); + using (Process git = _git.CreateProcess($"config {level} --unset-all {name} {valueRegex}")) + { + git.Start(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + 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})"); + } + } + } + + private string GetLevelFilterArg() + { + switch (_filterLevel) + { + case GitConfigurationLevel.ProgramData: + case GitConfigurationLevel.Xdg: + return null; + case GitConfigurationLevel.System: + return "--system"; + case GitConfigurationLevel.Global: + return "--global"; + case GitConfigurationLevel.Local: + return "--local"; + default: + return null; + } + } + } + + public static class GitConfigurationExtensions + { + /// + /// Get the value of a configuration entry as a string. + /// + /// A configuration entry with the specified key was not found. + /// Configuration object. + /// Configuration entry name. + /// Configuration entry value. + public static string GetValue(this IGitConfiguration config, string name) + { + if (!config.TryGetValue(name, out string value)) + { + throw new KeyNotFoundException($"Git configuration entry with the name '{name}' was not found."); + } + + return value; + } + + /// + /// Get the value of a configuration entry as a string. + /// + /// Configuration object. + /// Configuration section 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) + { + return GetValue(config, $"{section}.{property}"); + } + + /// + /// Get the value of a scoped configuration entry as a string. + /// + /// Configuration object. + /// Configuration section name. + /// Configuration section scope. + /// 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) + { + if (scope is null) + { + return GetValue(config, section, property); + } + + return GetValue(config, $"{section}.{scope}.{property}"); + } + + /// + /// Try and get the value of a configuration entry as a string. + /// + /// Configuration object. + /// Configuration section name. + /// 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) + { + return config.TryGetValue($"{section}.{property}", out value); + } + + /// + /// Try and get the value of a configuration entry as a string. + /// + /// Configuration object. + /// Configuration section name. + /// Configuration section scope. + /// 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) + { + if (scope is null) + { + return TryGetValue(config, section, property, out value); + } + + return config.TryGetValue($"{section}.{scope}.{property}", out value); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Gpg.cs b/src/shared/Microsoft.Git.CredentialManager/Gpg.cs new file mode 100644 index 000000000..9c670f2e8 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Gpg.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Diagnostics; + +namespace Microsoft.Git.CredentialManager +{ + public interface IGpg + { + string DecryptFile(string path); + + void EncryptFile(string path, string gpgId, string contents); + } + + public class Gpg : IGpg + { + private readonly string _gpgPath; + private readonly ISessionManager _sessionManager; + + public Gpg(string gpgPath, ISessionManager sessionManager) + { + EnsureArgument.NotNullOrWhiteSpace(gpgPath, nameof(gpgPath)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); + + _gpgPath = gpgPath; + _sessionManager = sessionManager; + } + + public string DecryptFile(string path) + { + var psi = new ProcessStartInfo(_gpgPath, $"--batch --decrypt \"{path}\"") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, // Suppress verbose decryption messages + }; + + PrepareEnvironment(psi); + + using (var gpg = Process.Start(psi)) + { + if (gpg is null) + { + throw new Exception("Failed to start gpg."); + } + + gpg.WaitForExit(); + + if (gpg.ExitCode != 0) + { + string stdout = gpg.StandardOutput.ReadToEnd(); + string stderr = gpg.StandardError.ReadToEnd(); + throw new Exception($"Failed to decrypt file '{path}' with gpg. exit={gpg.ExitCode}, out={stdout}, err={stderr}"); + } + + return gpg.StandardOutput.ReadToEnd(); + } + } + + public void EncryptFile(string path, string gpgId, string contents) + { + var psi = new ProcessStartInfo(_gpgPath, $"--encrypt --batch --recipient \"{gpgId}\" --output \"{path}\"") + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + PrepareEnvironment(psi); + + using (var gpg = Process.Start(psi)) + { + if (gpg is null) + { + throw new Exception("Failed to start gpg."); + } + + gpg.StandardInput.Write(contents); + gpg.StandardInput.Close(); + + gpg.WaitForExit(); + + if (gpg.ExitCode != 0) + { + string stdout = gpg.StandardOutput.ReadToEnd(); + string stderr = gpg.StandardError.ReadToEnd(); + throw new Exception($"Failed to encrypt file '{path}' with gpg. exit={gpg.ExitCode}, out={stdout}, err={stderr}"); + } + } + } + + private void PrepareEnvironment(ProcessStartInfo psi) + { + // If we're in a headless environment over SSH, and we don't have a GPG_TTY + // explicitly set, use the SSH_TTY variable for our GPG_TTY. + if (!_sessionManager.IsDesktopSession && + !psi.Environment.ContainsKey("GPG_TTY") && + psi.Environment.ContainsKey("SSH_TTY")) + { + psi.Environment["GPG_TTY"] = psi.Environment["SSH_TTY"]; + } + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/HostProvider.cs b/src/shared/Microsoft.Git.CredentialManager/HostProvider.cs index 6ba432977..b791ec561 100644 --- a/src/shared/Microsoft.Git.CredentialManager/HostProvider.cs +++ b/src/shared/Microsoft.Git.CredentialManager/HostProvider.cs @@ -79,16 +79,27 @@ protected HostProvider(ICommandContext context) public abstract bool IsSupported(InputArguments input); /// - /// Return a key that uniquely represents the given Git credential query arguments. + /// Return a string that uniquely identifies the service that a credential should be stored against. /// /// + /// /// This key forms part of the identifier used to retrieve and store credentials from the OS secure /// credential storage system. It is important the returned value is stable over time to avoid any /// potential re-authentication requests. + /// + /// + /// The default implementation returns the absolute URI formed by from the + /// without any userinfo component. Any trailing slashes are trimmed. + /// /// /// Input arguments of a Git credential query. - /// Stable credential key. - public abstract string GetCredentialKey(InputArguments input); + /// Credential service name. + public virtual string GetServiceName(InputArguments input) + { + // By default we assume the service name will be the absolute URI based on the + // input arguments from Git, without any userinfo part. + return input.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/'); + } /// /// Create a new credential used for accessing the remote Git repository on this hosting service. @@ -99,14 +110,14 @@ protected HostProvider(ICommandContext context) public virtual async Task GetCredentialAsync(InputArguments input) { - // Try and locate an existing PAT in the OS credential store - string credentialKey = GetCredentialKey(input); - Context.Trace.WriteLine($"Looking for existing credential in store with key '{credentialKey}'..."); - ICredential credential = Context.CredentialStore.Get(credentialKey); + // Try and locate an existing credential in the OS credential store + string service = GetServiceName(input); + Context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={input.UserName}..."); + ICredential credential = Context.CredentialStore.Get(service, input.UserName); if (credential == null) { - Context.Trace.WriteLine("No existing credential found."); + Context.Trace.WriteLine("No existing credentials found."); // No existing credential was found, create a new one Context.Trace.WriteLine("Creating new credential..."); @@ -123,25 +134,20 @@ public virtual async Task GetCredentialAsync(InputArguments input) public virtual Task StoreCredentialAsync(InputArguments input) { - // Create the credential based on Git's input - string userName = input.UserName; - string password = input.Password; + string service = GetServiceName(input); // WIA-authentication is signaled to Git as an empty username/password pair // and we will get called to 'store' these WIA credentials. // We avoid storing empty credentials. - if (string.IsNullOrWhiteSpace(userName) && string.IsNullOrWhiteSpace(password)) + if (string.IsNullOrWhiteSpace(input.UserName) && string.IsNullOrWhiteSpace(input.Password)) { Context.Trace.WriteLine("Not storing empty credential."); } else { - var credential = new GitCredential(userName, password); - // Add or update the credential in the store. - string credentialKey = GetCredentialKey(input); - Context.Trace.WriteLine($"Storing credential with key '{credentialKey}'..."); - Context.CredentialStore.AddOrUpdate(credentialKey, credential); + Context.Trace.WriteLine($"Storing credential with service={service} account={input.UserName}..."); + Context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); Context.Trace.WriteLine("Credential was successfully stored."); } @@ -150,46 +156,17 @@ public virtual Task StoreCredentialAsync(InputArguments input) public virtual Task EraseCredentialAsync(InputArguments input) { - // Try to locate an existing credential with the computed key - string credentialKey = GetCredentialKey(input); - Context.Trace.WriteLine($"Looking for existing credential in store with key '{credentialKey}'..."); - ICredential credential = Context.CredentialStore.Get(credentialKey); - if (credential == null) - { - Context.Trace.WriteLine("No stored credential was found."); - return Task.CompletedTask; - } - else - { - Context.Trace.WriteLine("Existing credential found."); - } - - // If we've been given a specific username and/or password we should only proceed - // to erase the stored credential if they match exactly - if (!string.IsNullOrWhiteSpace(input.UserName) && !StringComparer.Ordinal.Equals(input.UserName, credential.UserName)) - { - Context.Trace.WriteLine("Stored username does not match specified username - not erasing credential."); - Context.Trace.WriteLine($"\tInput username={input.UserName}"); - Context.Trace.WriteLine($"\tStored username={credential.UserName}"); - return Task.CompletedTask; - } - - if (!string.IsNullOrWhiteSpace(input.Password) && !StringComparer.Ordinal.Equals(input.Password, credential.Password)) - { - Context.Trace.WriteLine("Stored password does not match specified password - not erasing credential."); - Context.Trace.WriteLineSecrets("\tInput password={0}", new object[] {input.Password}); - Context.Trace.WriteLineSecrets("\tStored password={0}", new object[] {credential.Password}); - return Task.CompletedTask; - } + string service = GetServiceName(input); - Context.Trace.WriteLine("Erasing stored credential..."); - if (Context.CredentialStore.Remove(credentialKey)) + // Try to locate an existing credential + Context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={input.UserName}..."); + if (Context.CredentialStore.Remove(service, input.UserName)) { Context.Trace.WriteLine("Credential was successfully erased."); } else { - Context.Trace.WriteLine("Credential erase failed."); + Context.Trace.WriteLine("No credential was erased."); } return Task.CompletedTask; diff --git a/src/shared/Microsoft.Git.CredentialManager/ICredentialStore.cs b/src/shared/Microsoft.Git.CredentialManager/ICredentialStore.cs index c458a3836..d1459a234 100644 --- a/src/shared/Microsoft.Git.CredentialManager/ICredentialStore.cs +++ b/src/shared/Microsoft.Git.CredentialManager/ICredentialStore.cs @@ -9,24 +9,27 @@ namespace Microsoft.Git.CredentialManager public interface ICredentialStore { /// - /// Get credential from the store with the specified key. + /// Get the first credential from the store that matches the given query. /// - /// Key for credential to retrieve. - /// Stored credential or null if not found. - ICredential Get(string key); + /// Name of the service to match against. Use null to match all values. + /// Account name to match against. Use null to match all values. + /// First matching credential or null if none are found. + ICredential Get(string service, string account); /// /// Add or update credential in the store with the specified key. /// - /// Key for credential to add/update. - /// Credential to store. - void AddOrUpdate(string key, ICredential credential); + /// Name of the service this credential is for. Use null to match all values. + /// Account associated with this credential. Use null to match all values. + /// Secret value to store. + void AddOrUpdate(string service, string account, string secret); /// - /// Delete credential from the store with the specified key. + /// Delete credential from the store that matches the given query. /// - /// Key of credential to delete. + /// Name of the service to match against. Use null to match all values. + /// Account name to match against. Use null to match all values. /// True if the credential was deleted, false otherwise. - bool Remove(string key); + bool Remove(string service, string account); } } diff --git a/src/shared/Microsoft.Git.CredentialManager/IGit.cs b/src/shared/Microsoft.Git.CredentialManager/IGit.cs deleted file mode 100644 index 9f7cf0fd4..000000000 --- a/src/shared/Microsoft.Git.CredentialManager/IGit.cs +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; - -namespace Microsoft.Git.CredentialManager -{ - public interface IGitConfiguration : IDisposable - { - /// - /// Enumerate all configuration entries invoking the specified callback for each entry. - /// - /// Callback to invoke for each configuration entry. - void Enumerate(GitConfigurationEnumerationCallback cb); - - /// - /// Get a snapshot of the configuration filtered to the specified level. - /// - /// Configuration level filter. - /// Git configuration snapshot. - IGitConfiguration GetFilteredConfiguration(GitConfigurationLevel level); - - /// - /// Try and get the value of a configuration entry as a string. - /// - /// Configuration entry name. - /// Configuration entry value. - /// True if the value was found, false otherwise. - bool TryGetValue(string name, out string value); - - /// - /// Set the value of a configuration entry. - /// - /// Configuration entry name. - /// Configuration entry value. - void SetValue(string name, string value); - - /// - /// Deletes a configuration entry from the highest level. - /// - /// Configuration entry name. - void DeleteEntry(string name); - - /// - /// Get all values of a multivar configuration entry. - /// - /// Configuration entry name. - /// Regular expression to filter which variables we're interested in. Use null to indicate all. - /// All values of the multivar configuration entry. - IEnumerable GetMultivarValue(string name, string regexp); - - /// - /// Set a multivar configuration entry value. - /// - /// Configuration entry name. - /// Regular expression to indicate which values to replace. - /// Configuration entry value. - /// If the regular expression does not match any existing entry, a new entry is created. - void SetMultivarValue(string name, string regexp, string value); - - /// - /// Deletes one or several entries from a multivar. - /// - /// Configuration entry name. - /// Regular expression to indicate which values to delete. - void DeleteMultivarEntry(string name, string regexp); - } - - /// - /// Invoked for each Git configuration entry during an enumeration (). - /// - /// Name of the current configuration entry. - /// Value of the current configuration entry. - /// True to continue enumeration, false to stop enumeration. - public delegate bool GitConfigurationEnumerationCallback(string name, string value); - - public interface IGit : IDisposable - { - /// - /// Get a snapshot of the configuration for the system, user, and optionally a specified repository. - /// - /// Optional repository path from which to load local configuration. - /// Git configuration snapshot. - IGitConfiguration GetConfiguration(string repositoryPath); - - /// - /// Resolve the given path to a containing repository, or null if the path is not inside a Git repository. - /// - /// Path to resolve. - /// Git repository root path, or null if is not inside of a Git repository. - string GetRepositoryPath(string path); - } - - public enum GitConfigurationLevel - { - ProgramData, - System, - Xdg, - Global, - Local, - } - - public static class GitExtensions - { - /// - /// Get a snapshot of the configuration for the system and user. - /// - /// Git object. - /// Git configuration snapshot. - public static IGitConfiguration GetConfiguration(this IGit git) => git.GetConfiguration(null); - - /// - /// Get the value of a configuration entry as a string. - /// - /// A configuration entry with the specified key was not found. - /// Configuration object. - /// Configuration entry name. - /// Configuration entry value. - public static string GetValue(this IGitConfiguration config, string name) - { - if (!config.TryGetValue(name, out string value)) - { - throw new KeyNotFoundException($"Git configuration entry with the name '{name}' was not found."); - } - - return value; - } - - /// - /// Get the value of a configuration entry as a string. - /// - /// Configuration object. - /// Configuration section 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) - { - return GetValue(config, $"{section}.{property}"); - } - - /// - /// Get the value of a scoped configuration entry as a string. - /// - /// Configuration object. - /// Configuration section name. - /// Configuration section scope. - /// 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) - { - if (scope is null) - { - return GetValue(config, section, property); - } - - return GetValue(config, $"{section}.{scope}.{property}"); - } - - /// - /// Try and get the value of a configuration entry as a string. - /// - /// Configuration object. - /// Configuration section name. - /// 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) - { - return config.TryGetValue($"{section}.{property}", out value); - } - - /// - /// Try and get the value of a configuration entry as a string. - /// - /// Configuration object. - /// Configuration section name. - /// Configuration section scope. - /// 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) - { - if (scope is null) - { - return TryGetValue(config, section, property, out value); - } - - return config.TryGetValue($"{section}.{scope}.{property}", out value); - } - } -} diff --git a/src/shared/Microsoft.Git.CredentialManager/InputArguments.cs b/src/shared/Microsoft.Git.CredentialManager/InputArguments.cs index bb744025b..1a51c3e2c 100644 --- a/src/shared/Microsoft.Git.CredentialManager/InputArguments.cs +++ b/src/shared/Microsoft.Git.CredentialManager/InputArguments.cs @@ -48,22 +48,73 @@ public string this[string key] public string GetArgumentOrDefault(string key) { - return _dict.TryGetValue(key, out string value) ? value : null; + return TryGetArgument(key, out string value) ? value : null; } - public Uri GetRemoteUri() + public bool TryGetArgument(string key, out string value) + { + return _dict.TryGetValue(key, out value); + } + + public bool TryGetHostAndPort(out string host, out int? port) + { + host = null; + port = null; + + if (Host is null) + { + return false; + } + + // Split port number and hostname from host input argument + string[] hostParts = Host.Split(':'); + if (hostParts.Length > 0) + { + host = hostParts[0]; + } + + if (hostParts.Length > 1) + { + if (!int.TryParse(hostParts[1], out int portInt)) + { + return false; + } + + port = portInt; + } + + return true; + } + + public Uri GetRemoteUri(bool includeUser = false) { if (Protocol is null || Host is null) { return null; } - var ub = new UriBuilder(Protocol, Host) + string[] hostParts = Host.Split(':'); + if (hostParts.Length > 0) { - Path = Path - }; + var ub = new UriBuilder(Protocol, hostParts[0]) + { + Path = Path + }; + + if (hostParts.Length > 1 && int.TryParse(hostParts[1], out int port)) + { + ub.Port = port; + } + + if (includeUser) + { + ub.UserName = Uri.EscapeDataString(UserName); + } + + return ub.Uri; + } - return ub.Uri; + return null; } #endregion diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/LibGit2.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/LibGit2.cs deleted file mode 100644 index aea84476b..000000000 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/LibGit2.cs +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.IO; -using Microsoft.Git.CredentialManager.Interop.Native; -using static Microsoft.Git.CredentialManager.Interop.Native.git_config_level_t; -using static Microsoft.Git.CredentialManager.Interop.Native.LibGit2; - -namespace Microsoft.Git.CredentialManager.Interop -{ - public class LibGit2 : DisposableObject, IGit - { - private readonly ITrace _trace; - - public LibGit2(ITrace trace) - { - EnsureArgument.NotNull(trace, nameof(trace)); - - _trace = trace; - - _trace.WriteLine("Initializing libgit2..."); - git_libgit2_init(); - } - - public unsafe IGitConfiguration GetConfiguration(string repositoryPath) - { - ThrowIfDisposed(); - - _trace.WriteLine("Opening default Git configuration..."); - - // Open the default, non-repository-scoped configuration (progdata, system, xdg, global) - // Note that currently git_config_open_default(git_config** does not search /usr/local/etc for Git configuration - // files (such as those used by Homebrew installations on macOS, or locally built Git instances). - // We don't workaround this and push the responsibility to correct this with libgit2. - git_config* config; - ThrowIfError(git_config_open_default(&config), nameof(git_config_open_default)); - - // If we have a repository path then also include the local repository configuration - if (repositoryPath != null) - { - // We don't need to check for the file's existence since libgit2 will do that for us! - string repoConfigPath = Path.Combine(repositoryPath, "config"); - - // Add the repository configuration - _trace.WriteLine($"Adding local configuration from repository '{repositoryPath}'..."); - int error = git_config_add_file_ondisk(config, repoConfigPath, GIT_CONFIG_LEVEL_LOCAL, null, 0); - switch (error) - { - case GIT_OK: - case GIT_ENOTFOUND: // If the file was not found we should just continue - break; - default: - git_config_free(config); - ThrowIfError(error, nameof(git_config_add_file_ondisk)); - return null; - } - } - - return new LibGit2Configuration(_trace, config); - } - - public string GetRepositoryPath(string path) - { - ThrowIfDisposed(); - - var buf = new git_buf(); - _trace.WriteLine($"Discovering repository from path '{path}'..."); - int error = git_repository_discover(buf, path, true, null); - - try - { - switch (error) - { - case GIT_OK: - string repoPath = buf.ToString(); - _trace.WriteLine($"Found repository at '{repoPath}'."); - return repoPath; - case GIT_ENOTFOUND: - return null; - default: - ThrowIfError(error, nameof(git_repository_discover)); - return null; - } - } - finally - { - git_buf_dispose(buf); - } - } - - protected override void ReleaseUnmanagedResources() - { - git_libgit2_shutdown(); - base.ReleaseUnmanagedResources(); - } - } - - internal class LibGit2Configuration : DisposableObject, IGitConfiguration - { - private readonly ITrace _trace; - private readonly unsafe git_config* _config; - private readonly unsafe git_config* _snapshot; - - internal unsafe LibGit2Configuration(ITrace trace, git_config* config) - { - _trace = trace; - _config = config; - - // Create snapshot for reading values - _trace.WriteLine("Creating Git configuration snapshot..."); - git_config* snapshot = null; - ThrowIfError(git_config_snapshot(&snapshot, config), nameof(git_config_snapshot)); - _snapshot = snapshot; - } - - #region IGitConfiguration - - public unsafe void Enumerate(GitConfigurationEnumerationCallback cb) - { - ThrowIfDisposed(); - - int native_cb(git_config_entry entry, void* payload) - { - if (entry != null) - { - string name = entry.GetName(); - string value = entry.GetValue(); - - if (!cb(name, value)) - { - return GIT_ITEROVER; - } - } - - return GIT_OK; - } - - _trace.WriteLine("Enumerating Git configuration entries..."); - var result = git_config_foreach(_config, native_cb, (void*) IntPtr.Zero); - - switch (result) - { - case GIT_OK: - case GIT_ITEROVER: - _trace.WriteLine("Enumeration complete."); - break; - default: - ThrowIfError(result, nameof(git_config_foreach)); - break; - } - } - - public unsafe IGitConfiguration GetFilteredConfiguration(GitConfigurationLevel level) - { - git_config* filteredConfig; - - _trace.WriteLine($"Filtering default configuration set to '{level.ToString()}' level..."); - - // Filter to the requested level - switch (level) - { - case GitConfigurationLevel.ProgramData: - ThrowIfError(git_config_open_level(&filteredConfig, _config, GIT_CONFIG_LEVEL_PROGRAMDATA), - nameof(git_config_open_default)); - break; - - case GitConfigurationLevel.System: - ThrowIfError(git_config_open_level(&filteredConfig, _config, GIT_CONFIG_LEVEL_SYSTEM), - nameof(git_config_open_default)); - break; - - case GitConfigurationLevel.Xdg: - ThrowIfError(git_config_open_level(&filteredConfig, _config, GIT_CONFIG_LEVEL_XDG), - nameof(git_config_open_default)); - break; - - case GitConfigurationLevel.Global: - ThrowIfError(git_config_open_level(&filteredConfig, _config, GIT_CONFIG_LEVEL_GLOBAL), - nameof(git_config_open_default)); - break; - - case GitConfigurationLevel.Local: - ThrowIfError(git_config_open_level(&filteredConfig, _config, GIT_CONFIG_LEVEL_LOCAL), - nameof(git_config_open_default)); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(level), level, null); - } - - return new LibGit2Configuration(_trace, filteredConfig); - } - - public unsafe bool TryGetValue(string name, out string value) - { - ThrowIfDisposed(); - - _trace.WriteLine($"Reading Git configuration entry '{name}'..."); - int result = git_config_get_string(out value, _snapshot, name); - - switch (result) - { - case GIT_OK: - _trace.WriteLine($"Successfully read value '{value}'."); - return true; - case GIT_ENOTFOUND: - _trace.WriteLine("No entry found."); - value = null; - break; - default: - ThrowIfError(result, nameof(git_config_get_string)); - break; - } - - return false; - } - - public unsafe void SetValue(string name, string value) - { - _trace.WriteLine($"Setting Git configuration entry '{name}' to '{value}'..."); - ThrowIfError(git_config_set_string(_config, name, value), nameof(git_config_set_string)); - } - - public unsafe void DeleteEntry(string name) - { - _trace.WriteLine($"Deleting Git configuration entry '{name}'..."); - - int result = git_config_delete_entry(_config, name); - switch (result) - { - case GIT_ENOTFOUND: - // Do nothing if asked to delete non-existent key - break; - - default: - ThrowIfError(result, nameof(git_config_delete_entry)); - break; - } - } - - public unsafe IEnumerable GetMultivarValue(string name, string regexp) - { - _trace.WriteLine($"Reading Git configuration multivar '{name}' (regexp: '{regexp}')..."); - - var values = new List(); - - int value_callback(git_config_entry entry, void* payload) - { - string value = entry.GetValue(); - _trace.WriteLine($"Found multivar value '{value}'."); - values.Add(value); - return 0; - } - - int result = git_config_get_multivar_foreach(_config, name, regexp, value_callback, (void*) IntPtr.Zero); - switch (result) - { - case GIT_ENOTFOUND: - // Do nothing if asked to enumerate non-existent multivar key - _trace.WriteLine("No entry found."); - break; - - default: - ThrowIfError(result, nameof(git_config_get_multivar_foreach)); - break; - } - - return values; - } - - public unsafe void SetMultivarValue(string name, string regexp, string value) - { - _trace.WriteLine($"Setting Git configuration multivar '{name}' (regexp: '{regexp}') to '{value}'..."); - ThrowIfError(git_config_set_multivar(_config, name, regexp, value), nameof(git_config_set_multivar)); - } - - public unsafe void DeleteMultivarEntry(string name, string regexp) - { - _trace.WriteLine($"Deleting Git configuration multivar '{name}' (regexp: '{regexp}')..."); - - int result = git_config_delete_multivar(_config, name, regexp); - switch (result) - { - case GIT_ENOTFOUND: - // Do nothing if asked to delete non-existent key - break; - - default: - ThrowIfError(result, nameof(git_config_delete_multivar)); - break; - } - } - - #endregion - - protected override void ReleaseUnmanagedResources() - { - unsafe - { - git_config_free(_snapshot); - git_config_free(_config); - base.ReleaseUnmanagedResources(); - } - } - } -} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxCredentialStore.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxCredentialStore.cs new file mode 100644 index 000000000..c02253db9 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxCredentialStore.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.IO; +using System.Text; +using Microsoft.Git.CredentialManager.Interop.Posix; + +namespace Microsoft.Git.CredentialManager.Interop.Linux +{ + public class LinuxCredentialStore : ICredentialStore + { + private const string SecretServiceStoreOption = "secretservice"; + private const string GpgStoreOption = "gpg"; + private const string PlaintextStoreOption = "plaintext"; + + private readonly IFileSystem _fileSystem; + private readonly ISettings _settings; + private readonly ISessionManager _sessionManager; + private readonly IGpg _gpg; + private readonly IEnvironment _environment; + + private ICredentialStore _backingStore; + + public LinuxCredentialStore(IFileSystem fileSystem, ISettings settings, ISessionManager sessionManager, IGpg gpg, IEnvironment environment) + { + EnsureArgument.NotNull(fileSystem, nameof(fileSystem)); + EnsureArgument.NotNull(settings, nameof(settings)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); + EnsureArgument.NotNull(gpg, nameof(gpg)); + EnsureArgument.NotNull(environment, nameof(environment)); + + _fileSystem = fileSystem; + _settings = settings; + _sessionManager = sessionManager; + _gpg = gpg; + _environment = environment; + } + + #region ICredentialStore + + public ICredential Get(string service, string account) + { + EnsureBackingStore(); + return _backingStore.Get(service, account); + } + + public void AddOrUpdate(string service, string account, string secret) + { + EnsureBackingStore(); + _backingStore.AddOrUpdate(service, account, secret); + } + + public bool Remove(string service, string account) + { + EnsureBackingStore(); + return _backingStore.Remove(service, account); + } + + #endregion + + private void EnsureBackingStore() + { + if (_backingStore != null) + { + return; + } + + string ns = _settings.CredentialNamespace; + string credStoreName = _settings.CredentialBackingStore?.ToLowerInvariant(); + + switch (credStoreName) + { + case SecretServiceStoreOption: + ValidateSecretService(); + _backingStore = new SecretServiceCollection(ns); + break; + + case GpgStoreOption: + ValidateGpgPass(out string gpgStoreRoot); + _backingStore = new GpgPassCredentialStore(_fileSystem, _gpg, gpgStoreRoot, ns); + break; + + case PlaintextStoreOption: + ValidatePlaintext(out string plainStoreRoot); + _backingStore = new PlaintextCredentialStore(_fileSystem, plainStoreRoot, ns); + break; + + default: + var sb = new StringBuilder(); + sb.AppendLine("No credential backing store has been selected."); + sb.AppendFormat( + "{3}Set the {0} environment variable or the {1}.{2} Git configuration setting to one of the following options:{3}{3}", + Constants.EnvironmentVariables.GcmCredentialStore, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.CredentialStore, + Environment.NewLine); + sb.AppendFormat(" {0,-13} : freedesktop.org Secret Service (requires graphical interface){1}", SecretServiceStoreOption, Environment.NewLine); + sb.AppendFormat(" {0,-13} : GNU `pass` compatible credential storage (requires GPG and `pass`){1}", GpgStoreOption, Environment.NewLine); + sb.AppendFormat(" {0,-13} : store credentials in plain-text files (UNSECURE){1}", PlaintextStoreOption, Environment.NewLine); + sb.AppendLine(); + sb.AppendLine($"See {Constants.HelpUrls.GcmLinuxCredStores} for more information."); + throw new Exception(sb.ToString()); + } + } + + private void ValidateSecretService() + { + if (!_sessionManager.IsDesktopSession) + { + throw new Exception($"Cannot use the '{SecretServiceStoreOption}' credential backing store without a graphical interface present." + + Environment.NewLine + $"See {Constants.HelpUrls.GcmLinuxCredStores} for more information."); + } + } + + private void ValidateGpgPass(out string storeRoot) + { + // If we are in a headless environment, and don't have the GPG_TTY or SSH_TTY + // variables set, then error - we need a TTY device path for pin-entry to work headless. + if (!_sessionManager.IsDesktopSession && + !_environment.Variables.ContainsKey("GPG_TTY") && + !_environment.Variables.ContainsKey("SSH_TTY")) + { + throw new Exception("GPG_TTY is not set; add `export GPG_TTY=$(tty)` to your profile." + + Environment.NewLine + $"See {Constants.HelpUrls.GcmLinuxCredStores} for more information."); + } + + // Check for a redirected pass store location + if (!_settings.TryGetSetting( + GpgPassCredentialStore.PasswordStoreDirEnvar, + null, null, + out storeRoot)) + { + // Use default store root at ~/.password-store + storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".password-store"); + } + + // Check we have a GPG ID to sign credential files with + string gpgIdFile = Path.Combine(storeRoot, ".gpg-id"); + if (!_fileSystem.FileExists(gpgIdFile)) + { + throw new Exception($"Password store has not been initialized at '{storeRoot}'; run `pass init ` to initialize the store." + + Environment.NewLine + $"See {Constants.HelpUrls.GcmLinuxCredStores} for more information."); + } + } + + private void ValidatePlaintext(out string storeRoot) + { + // Check for a redirected credential store location + if (!_settings.TryGetSetting( + Constants.EnvironmentVariables.GcmPlaintextStorePath, + Constants.GitConfiguration.Credential.SectionName, Constants.GitConfiguration.Credential.PlaintextStorePath, + out storeRoot)) + { + // Use default store root at ~/.gcm/store + storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Constants.GcmConfigDirectoryName, "store"); + } + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxFileSystem.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxFileSystem.cs new file mode 100644 index 000000000..c13e0bd47 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxFileSystem.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.IO; +using Microsoft.Git.CredentialManager.Interop.Posix; + +namespace Microsoft.Git.CredentialManager.Interop.Linux +{ + public class LinuxFileSystem : PosixFileSystem + { + public override bool IsSamePath(string a, string b) + { + a = Path.GetFileName(a); + b = Path.GetFileName(b); + + return StringComparer.Ordinal.Equals(a, b); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxSystemPrompts.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxSystemPrompts.cs new file mode 100644 index 000000000..f519e5644 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/LinuxSystemPrompts.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.Git.CredentialManager.Interop.Linux +{ + public class LinuxSystemPrompts : ISystemPrompts + { + public object ParentWindowId { get; set; } + + public bool ShowCredentialPrompt(string resource, string userName, out ICredential credential) + { + throw new System.NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Glib.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Glib.cs new file mode 100644 index 000000000..5d1cd74a8 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Glib.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.Interop.Linux.Native +{ + public static class Glib + { + private const string LibraryName = "libglib-2.0.so.0"; + + public struct GHashTable { /* transparent */ } + + [StructLayout(LayoutKind.Sequential)] + public struct GList + { + public IntPtr data; + public IntPtr next; + public IntPtr prev; + } + + [StructLayout(LayoutKind.Sequential)] + public struct GError + { + public int domain; + public int code; + public IntPtr message; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate uint GHashFunc(IntPtr key); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate bool GEqualFunc(IntPtr a, IntPtr b); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void GDestroyNotify(IntPtr data); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern uint g_str_hash(IntPtr key); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern bool g_str_equal(IntPtr a, IntPtr b); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe GHashTable* g_hash_table_new(GHashFunc hash_func, GEqualFunc key_equal_func); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe GHashTable* g_hash_table_new_full( + GHashFunc hash_func, + GEqualFunc key_equal_func, + GDestroyNotify key_destroy_func, + GDestroyNotify value_destroy_func); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe void g_hash_table_destroy(GHashTable* hash_table); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe bool g_hash_table_insert(GHashTable* hash_table, IntPtr key, IntPtr value); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe IntPtr g_hash_table_lookup(GHashTable* hash_table, IntPtr key); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe void g_list_free_full(GList* list, GDestroyNotify free_func); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe void g_hash_table_unref(GHashTable* hash_table); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe void g_error_free(GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void g_free(IntPtr mem); + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Gobject.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Gobject.cs new file mode 100644 index 000000000..c468c10f2 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Gobject.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.Interop.Linux.Native +{ + public static class Gobject + { + private const string LibraryName = "libgobject-2.0.so.0"; + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void g_object_ref(IntPtr @object); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void g_object_unref(IntPtr @object); + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Libsecret.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Libsecret.cs new file mode 100644 index 000000000..2d4560f68 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/Native/Libsecret.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.Interop.Linux.Native +{ + public static class Libsecret + { + private const string LibraryName = "libsecret-1.so.0"; + + public enum SecretSchemaAttributeType + { + SECRET_SCHEMA_ATTRIBUTE_STRING = 0, + SECRET_SCHEMA_ATTRIBUTE_INTEGER = 1, + SECRET_SCHEMA_ATTRIBUTE_BOOLEAN = 2, + } + + [StructLayout(LayoutKind.Sequential)] + public struct SecretSchemaAttribute + { + public string name; + public SecretSchemaAttributeType type; + } + + [Flags] + public enum SecretSchemaFlags + { + SECRET_SCHEMA_NONE = 0, + SECRET_SCHEMA_DONT_MATCH_NAME = 1 << 1, + } + + [StructLayout(LayoutKind.Sequential)] + public struct SecretSchema + { + public string name; + public SecretSchemaFlags flags; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + public SecretSchemaAttribute[] attributes; + + int reserved; + IntPtr reserved1; + IntPtr reserved2; + IntPtr reserved3; + IntPtr reserved4; + IntPtr reserved5; + IntPtr reserved6; + IntPtr reserved7; + } + public struct SecretService { /* transparent */ } + + [Flags] + public enum SecretServiceFlags + { + SECRET_SERVICE_NONE = 0, + SECRET_SERVICE_OPEN_SESSION = 1 << 1, + SECRET_SERVICE_LOAD_COLLECTIONS = 1 << 2, + } + + [Flags] + public enum SecretSearchFlags + { + SECRET_SEARCH_NONE = 0, + SECRET_SEARCH_ALL = 1 << 1, + SECRET_SEARCH_UNLOCK = 1 << 2, + SECRET_SEARCH_LOAD_SECRETS = 1 << 3, + } + + public struct SecretItem { /* transparent */ } + + public struct SecretValue { /* transparent */ } + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe SecretService* secret_service_get_sync( + SecretServiceFlags flags, + IntPtr cancellable, + out Glib.GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe Glib.GList* secret_service_search_sync( + SecretService* service, + ref SecretSchema schema, + Glib.GHashTable* attributes, + SecretSearchFlags flags, + IntPtr cancellable, + out Glib.GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe bool secret_service_store_sync( + SecretService* service, + ref SecretSchema schema, + Glib.GHashTable *attributes, + string collection, + string label, + SecretValue *value, + IntPtr cancellable, + out Glib.GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe bool secret_service_clear_sync( + SecretService* service, + ref SecretSchema schema, + Glib.GHashTable *attributes, + IntPtr cancellable, + out Glib.GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern string secret_item_get_label(IntPtr self); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe Glib.GHashTable* secret_item_get_attributes(SecretItem* item); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe void secret_item_load_secret_sync( + SecretItem* self, + IntPtr cancellable, + out Glib.GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe int secret_service_unlock_sync(SecretService* service, + Glib.GList* objects, + IntPtr cancellable, + out Glib.GList* unlocked, + out Glib.GError* error); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe bool secret_item_get_locked(SecretItem *self); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe SecretValue* secret_item_get_secret(SecretItem* item); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe SecretValue* secret_value_new( + byte[] secret, + int length, + string content_type); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe IntPtr secret_value_get(SecretValue* value, out int length); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe void secret_value_unref(SecretValue* value); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe IntPtr secret_value_unref_to_password(SecretValue *value, out int length); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void secret_password_free(IntPtr password); + + [DllImport(LibraryName, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe bool secret_item_delete_sync(SecretItem* self, IntPtr cancellable, out Glib.GError* error); + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/SecretServiceCollection.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/SecretServiceCollection.cs new file mode 100644 index 000000000..09ce5e19a --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/SecretServiceCollection.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; +using System.Text; +using static Microsoft.Git.CredentialManager.Interop.Linux.Native.Gobject; +using static Microsoft.Git.CredentialManager.Interop.Linux.Native.Glib; +using static Microsoft.Git.CredentialManager.Interop.Linux.Native.Libsecret; +using static Microsoft.Git.CredentialManager.Interop.Linux.Native.Libsecret.SecretSchemaAttributeType; +using static Microsoft.Git.CredentialManager.Interop.Linux.Native.Libsecret.SecretSchemaFlags; + +namespace Microsoft.Git.CredentialManager.Interop.Linux +{ + public class SecretServiceCollection : ICredentialStore + { + private const string SchemaName = "com.microsoft.GitCredentialManager"; + private const string ServiceAttributeName = "service"; + private const string AccountAttributeName = "account"; + private const string PlainTextContentType = "plain/text"; + + private readonly string _namespace; + + #region Constructors + + /// + /// Open the default secret collection for the current user. + /// + /// Optional namespace to scope credential operations. + /// Default secret collection. + public SecretServiceCollection(string @namespace) + { + PlatformUtils.EnsureLinux(); + _namespace = @namespace; + } + + #endregion + + #region ICredentialStore + + public unsafe ICredential Get(string service, string account) + { + GHashTable* queryAttrs = null; + GList* results = null; + GError* error = null; + + try + { + SecretService* secService = GetSecretService(); + + queryAttrs = CreateSearchQuery(service, account); + + SecretSchema schema = GetSchema(); + + // Execute search query and return the first result + results = secret_service_search_sync( + secService, + ref schema, + queryAttrs, + SecretSearchFlags.SECRET_SEARCH_UNLOCK, + IntPtr.Zero, + out error); + + if (error != null) + { + int code = error->code; + string message = Marshal.PtrToStringAuto(error->message)!; + throw new InteropException("Failed to search for credentials", code, new Exception(message)); + } + + if (results != null && results->data != null) + { + SecretItem* item = (SecretItem*) results->data; + + // Although we've unlocked the collection during the search call, + // an item can also be individually locked within a collection. + // If the item is locked we should try and unlock it. + if (secret_item_get_locked(item)) + { + var toUnlockList = new GList + { + data = (IntPtr) item, + next = IntPtr.Zero, + prev = IntPtr.Zero + }; + + int numUnlocked = secret_service_unlock_sync( + secService, + &toUnlockList, + IntPtr.Zero, + out _, + out error + ); + + if (numUnlocked != 1) + { + throw new InteropException("Failed to unlock item", numUnlocked); + } + } + + return CreateCredentialFromItem(item); + } + + return null; + } + finally + { + if (queryAttrs != null) g_hash_table_destroy(queryAttrs); + if (error != null) g_error_free(error); + if (results != null) g_list_free_full(results, g_object_unref); + } + } + + public unsafe void AddOrUpdate(string service, string account, string secret) + { + GHashTable* attributes = null; + SecretValue* secretValue = null; + GError *error = null; + + try + { + SecretService* secService = GetSecretService(); + + // Create attributes for the key and user + attributes = g_hash_table_new_full(g_str_hash, g_str_equal, + Marshal.FreeHGlobal, Marshal.FreeHGlobal); + + string fullServiceName = CreateServiceName(service); + IntPtr serviceKeyPtr = Marshal.StringToHGlobalAnsi(ServiceAttributeName); + IntPtr serviceValuePtr = Marshal.StringToHGlobalAnsi(fullServiceName); + g_hash_table_insert(attributes, serviceKeyPtr, serviceValuePtr); + + if (!string.IsNullOrWhiteSpace(account)) + { + IntPtr accountKeyPtr = Marshal.StringToHGlobalAnsi(AccountAttributeName); + IntPtr accountValuePtr = Marshal.StringToHGlobalAnsi(account); + g_hash_table_insert(attributes, accountKeyPtr, accountValuePtr); + } + + // Create the secret value object from the secret string + byte[] secretBytes = Encoding.UTF8.GetBytes(secret); + secretValue = secret_value_new(secretBytes, secretBytes.Length, PlainTextContentType); + + SecretSchema schema = GetSchema(); + + // Store the secret with the associated attributes + bool result = secret_service_store_sync( + secService, + ref schema, + attributes, + null, + fullServiceName, // Use full service name as label + secretValue, + IntPtr.Zero, + out error); + + if (error != null) + { + int code = error->code; + string message = Marshal.PtrToStringAuto(error->message)!; + throw new InteropException("Failed to store credentials", code, new Exception(message)); + } + + if (!result) + { + throw new InteropException("Failed to store credentials", -1); + } + } + finally + { + if (attributes != null) g_hash_table_destroy(attributes); + if (secretValue != null) secret_value_unref(secretValue); + if (error != null) g_error_free(error); + } + } + + public unsafe bool Remove(string service, string account) + { + GHashTable* attributes = null; + GError* error = null; + + try + { + SecretService* secService = GetSecretService(); + + // Create search query + attributes = CreateSearchQuery(service, account); + + SecretSchema schema = GetSchema(); + + // Erase the secret with the specified key + bool result = secret_service_clear_sync( + secService, + ref schema, + attributes, + IntPtr.Zero, + out error); + + if (error != null) + { + int code = error->code; + string message = Marshal.PtrToStringAuto(error->message)!; + throw new InteropException("Failed to erase credentials", code, new Exception(message)); + } + + return result; + } + finally + { + if (attributes != null) g_hash_table_destroy(attributes); + if (error != null) g_error_free(error); + } + } + + #endregion + + private unsafe GHashTable* CreateSearchQuery(string service, string account) + { + // Build search query + GHashTable* queryAttrs = g_hash_table_new_full( + g_str_hash, g_str_equal, + Marshal.FreeHGlobal, Marshal.FreeHGlobal); + + // If we've be given a service then filter on the service attribute + if (!string.IsNullOrWhiteSpace(service)) + { + string fullServiceName = CreateServiceName(service); + IntPtr keyPtr = Marshal.StringToHGlobalAnsi(ServiceAttributeName); + IntPtr valuePtr = Marshal.StringToHGlobalAnsi(fullServiceName); + g_hash_table_insert(queryAttrs, keyPtr, valuePtr); + } + + // If we've be given a username then filter on the account attribute + if (!string.IsNullOrWhiteSpace(account)) + { + IntPtr keyPtr = Marshal.StringToHGlobalAnsi(AccountAttributeName); + IntPtr valuePtr = Marshal.StringToHGlobalAnsi(account); + g_hash_table_insert(queryAttrs, keyPtr, valuePtr); + } + + return queryAttrs; + } + + private static unsafe ICredential CreateCredentialFromItem(SecretItem* item) + { + GHashTable* secretAttrs = null; + IntPtr serviceKeyPtr = IntPtr.Zero; + IntPtr accountKeyPtr = IntPtr.Zero; + IntPtr passwordPtr = IntPtr.Zero; + GError* error = null; + + try + { + secretAttrs = secret_item_get_attributes(item); + + // Extract the service attribute + serviceKeyPtr = Marshal.StringToHGlobalAnsi(ServiceAttributeName); + IntPtr serviceValuePtr = g_hash_table_lookup(secretAttrs, serviceKeyPtr); + string service = Marshal.PtrToStringAuto(serviceValuePtr); + + // Extract the account attribute + accountKeyPtr = Marshal.StringToHGlobalAnsi(AccountAttributeName); + IntPtr accountValuePtr = g_hash_table_lookup(secretAttrs, accountKeyPtr); + string account = Marshal.PtrToStringAuto(accountValuePtr); + + // Load the secret value + secret_item_load_secret_sync(item, IntPtr.Zero, out error); + SecretValue* value = secret_item_get_secret(item); + if (value == null) + { + throw new InteropException("Failed to load secret", -1); + } + + // Extract the secret/password + passwordPtr = secret_value_unref_to_password(value, out int passwordLength); + string password = Marshal.PtrToStringAuto(passwordPtr, passwordLength); + + return new SecretServiceCredential(service, account, password); + } + finally + { + if (secretAttrs != null) g_hash_table_unref(secretAttrs); + if (accountKeyPtr != IntPtr.Zero) Marshal.FreeHGlobal(accountKeyPtr); + if (serviceKeyPtr != IntPtr.Zero) Marshal.FreeHGlobal(serviceKeyPtr); + if (passwordPtr != IntPtr.Zero) secret_password_free(passwordPtr); + if (error != null) g_error_free(error); + } + } + + private string CreateServiceName(string service) + { + var sb = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(_namespace)) + { + sb.AppendFormat("{0}:", _namespace); + } + + sb.Append(service); + return sb.ToString(); + } + + private static unsafe SecretService* GetSecretService() + { + // Get a handle to the default secret service, open a session, + // and load all collections + SecretService* service = secret_service_get_sync( + SecretServiceFlags.SECRET_SERVICE_OPEN_SESSION | SecretServiceFlags.SECRET_SERVICE_LOAD_COLLECTIONS, + IntPtr.Zero, out GError* error); + + if (error != null) + { + int code = error->code; + string message = Marshal.PtrToStringAuto(error->message)!; + g_error_free(error); + throw new InteropException("Failed to open secret service session", code, new Exception(message)); + } + + return service; + } + + private static SecretSchema GetSchema() + { + var schema = new SecretSchema + { + name = SchemaName, + flags = SECRET_SCHEMA_DONT_MATCH_NAME, + attributes = new SecretSchemaAttribute[32] + }; + + schema.attributes[0] = new SecretSchemaAttribute + { + name = ServiceAttributeName, + type = SECRET_SCHEMA_ATTRIBUTE_STRING + }; + + schema.attributes[1] = new SecretSchemaAttribute + { + name = AccountAttributeName, + type = SECRET_SCHEMA_ATTRIBUTE_STRING + }; + + return schema; + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/SecretServiceCredential.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/SecretServiceCredential.cs new file mode 100644 index 000000000..2335145c6 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Linux/SecretServiceCredential.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System.Diagnostics; + +namespace Microsoft.Git.CredentialManager.Interop.Linux +{ + [DebuggerDisplay("{DebuggerDisplay}")] + public class SecretServiceCredential : ICredential + { + internal SecretServiceCredential(string service, string account, string password) + { + Service = service; + Account = account; + Password = password; + } + + public string Service { get; } + + public string Account { get; } + + public string Password { get; } + + private string DebuggerDisplay => $"[Service: {Service}, Account: {Account}]"; + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSFileSystem.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSFileSystem.cs index c86edbe33..c5c64e8a0 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSFileSystem.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSFileSystem.cs @@ -2,10 +2,11 @@ // Licensed under the MIT license. using System; using System.IO; +using Microsoft.Git.CredentialManager.Interop.Posix; namespace Microsoft.Git.CredentialManager.Interop.MacOS { - public class MacOSFileSystem : FileSystem + public class MacOSFileSystem : PosixFileSystem { public override bool IsSamePath(string a, string b) { diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychain.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychain.cs index daebe77db..34634c26d 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychain.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychain.cs @@ -5,108 +5,132 @@ using System.Runtime.InteropServices; using System.Text; using Microsoft.Git.CredentialManager.Interop.MacOS.Native; +using static Microsoft.Git.CredentialManager.Interop.MacOS.Native.CoreFoundation; using static Microsoft.Git.CredentialManager.Interop.MacOS.Native.SecurityFramework; namespace Microsoft.Git.CredentialManager.Interop.MacOS { public class MacOSKeychain : ICredentialStore { + private readonly string _namespace; + #region Constructors /// /// Open the default keychain (current user's login keychain). /// + /// Optional namespace to scope credential operations. /// Default keychain. - public static MacOSKeychain Open() - { - return new MacOSKeychain(); - } - - private MacOSKeychain() + public MacOSKeychain(string @namespace = null) { PlatformUtils.EnsureMacOS(); + _namespace = @namespace; } #endregion #region ICredentialStore - public ICredential Get(string key) + public ICredential Get(string service, string account) { - IntPtr passwordData = IntPtr.Zero; - IntPtr itemRef = IntPtr.Zero; + IntPtr query = IntPtr.Zero; + IntPtr resultPtr = IntPtr.Zero; + IntPtr servicePtr = IntPtr.Zero; + IntPtr accountPtr = IntPtr.Zero; try { - // Find the item (itemRef) and password (passwordData) in the keychain - int findResult = SecKeychainFindGenericPassword( - IntPtr.Zero, (uint) key.Length, key, 0, null, - out uint passwordLength, out passwordData, out itemRef); + query = CFDictionaryCreateMutable( + IntPtr.Zero, + 0, + IntPtr.Zero, IntPtr.Zero); - switch (findResult) + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword); + CFDictionaryAddValue(query, kSecMatchLimit, kSecMatchLimitOne); + CFDictionaryAddValue(query, kSecReturnData, kCFBooleanTrue); + CFDictionaryAddValue(query, kSecReturnAttributes, kCFBooleanTrue); + + if (!string.IsNullOrWhiteSpace(service)) { - case OK: - // Get and decode the user name from the 'account name' attribute - byte[] userNameBytes = GetAccountNameAttributeData(itemRef); - string userName = Encoding.UTF8.GetString(userNameBytes); + string fullService = CreateServiceName(service); + servicePtr = CreateCFStringUtf8(fullService); + CFDictionaryAddValue(query, kSecAttrService, servicePtr); + } + + if (!string.IsNullOrWhiteSpace(account)) + { + accountPtr = CreateCFStringUtf8(account); + CFDictionaryAddValue(query, kSecAttrAccount, accountPtr); + } + + int searchResult = SecItemCopyMatching(query, out resultPtr); - // Decode the password from the raw data - byte[] passwordBytes = InteropUtils.ToByteArray(passwordData, passwordLength); - string password = Encoding.UTF8.GetString(passwordBytes); + switch (searchResult) + { + case OK: + int typeId = CFGetTypeID(resultPtr); + Debug.Assert(typeId != CFArrayGetTypeID(), "Returned more than one keychain item in search"); + if (typeId == CFDictionaryGetTypeID()) + { + return CreateCredentialFromAttributes(resultPtr); + } - return new GitCredential(userName, password); + throw new InteropException($"Unknown keychain search result type CFTypeID: {typeId}.", -1); case ErrorSecItemNotFound: return null; default: - ThrowIfError(findResult); + ThrowIfError(searchResult); return null; } } finally { - if (passwordData != IntPtr.Zero) - { - SecKeychainItemFreeContent(IntPtr.Zero, passwordData); - } - - if (itemRef != IntPtr.Zero) - { - CoreFoundation.CFRelease(itemRef); - } + if (query != IntPtr.Zero) CFRelease(query); + if (servicePtr != IntPtr.Zero) CFRelease(servicePtr); + if (accountPtr != IntPtr.Zero) CFRelease(accountPtr); + if (resultPtr != IntPtr.Zero) CFRelease(resultPtr); } } - public void AddOrUpdate(string key, ICredential credential) + public void AddOrUpdate(string service, string account, string secret) { - byte[] passwordBytes = Encoding.UTF8.GetBytes(credential.Password); + EnsureArgument.NotNullOrWhiteSpace(service, nameof(service)); + + byte[] secretBytes = Encoding.UTF8.GetBytes(secret); IntPtr passwordData = IntPtr.Zero; IntPtr itemRef = IntPtr.Zero; + string serviceName = CreateServiceName(service); + + + uint serviceNameLength = (uint) serviceName.Length; + uint accountLength = (uint) (account?.Length ?? 0); + try { // Check if an entry already exists in the keychain int findResult = SecKeychainFindGenericPassword( - IntPtr.Zero, (uint) key.Length, key, (uint) credential.UserName.Length, credential.UserName, + IntPtr.Zero, serviceNameLength, serviceName, accountLength, account, out uint _, out passwordData, out itemRef); switch (findResult) { - // Create new entry + // Update existing entry case OK: ThrowIfError( - SecKeychainItemModifyAttributesAndData(itemRef, IntPtr.Zero, (uint) passwordBytes.Length, passwordBytes), + SecKeychainItemModifyAttributesAndData(itemRef, IntPtr.Zero, (uint) secretBytes.Length, secretBytes), "Could not update existing item" ); break; - // Update existing entry + // Create new entry case ErrorSecItemNotFound: ThrowIfError( - SecKeychainAddGenericPassword(IntPtr.Zero, (uint) key.Length, key, (uint) credential.UserName.Length, - credential.UserName, (uint) passwordBytes.Length, passwordBytes, out itemRef), + SecKeychainAddGenericPassword(IntPtr.Zero, serviceNameLength, serviceName, accountLength, + account, (uint) secretBytes.Length, secretBytes, out itemRef), "Could not create new item" ); break; @@ -125,27 +149,50 @@ public void AddOrUpdate(string key, ICredential credential) if (itemRef != IntPtr.Zero) { - CoreFoundation.CFRelease(itemRef); + CFRelease(itemRef); } } } - public bool Remove(string key) + public bool Remove(string service, string account) { - IntPtr passwordData = IntPtr.Zero; - IntPtr itemRef = IntPtr.Zero; + IntPtr query = IntPtr.Zero; + IntPtr itemRefPtr = IntPtr.Zero; + IntPtr servicePtr = IntPtr.Zero; + IntPtr accountPtr = IntPtr.Zero; try { - int findResult = SecKeychainFindGenericPassword( - IntPtr.Zero, (uint) key.Length, key, 0, null, - out _, out passwordData, out itemRef); + query = CFDictionaryCreateMutable( + IntPtr.Zero, + 0, + IntPtr.Zero, IntPtr.Zero); - switch (findResult) + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword); + CFDictionaryAddValue(query, kSecMatchLimit, kSecMatchLimitOne); + CFDictionaryAddValue(query, kSecReturnRef, kCFBooleanTrue); + + if (!string.IsNullOrWhiteSpace(service)) + { + string fullService = CreateServiceName(service); + servicePtr = CreateCFStringUtf8(fullService); + CFDictionaryAddValue(query, kSecAttrService, servicePtr); + } + + if (!string.IsNullOrWhiteSpace(account)) + { + accountPtr = CreateCFStringUtf8(account); + CFDictionaryAddValue(query, kSecAttrAccount, accountPtr); + } + + // Search for the credential to delete and get the SecKeychainItem ref. + int searchResult = SecItemCopyMatching(query, out itemRefPtr); + switch (searchResult) { case OK: + // Delete the item ThrowIfError( - SecKeychainItemDelete(itemRef) + SecKeychainItemDelete(itemRefPtr) ); return true; @@ -153,82 +200,89 @@ public bool Remove(string key) return false; default: - ThrowIfError(findResult); + ThrowIfError(searchResult); return false; } } finally { - if (passwordData != IntPtr.Zero) - { - SecKeychainItemFreeContent(IntPtr.Zero, passwordData); - } - - if (itemRef != IntPtr.Zero) - { - CoreFoundation.CFRelease(itemRef); - } + if (query != IntPtr.Zero) CFRelease(query); + if (itemRefPtr != IntPtr.Zero) CFRelease(itemRefPtr); + if (servicePtr != IntPtr.Zero) CFRelease(servicePtr); + if (accountPtr != IntPtr.Zero) CFRelease(accountPtr); } } #endregion - #region Private Methods + private static IntPtr CreateCFStringUtf8(string str) + { + byte[] bytes = Encoding.UTF8.GetBytes(str); + return CFStringCreateWithBytes(IntPtr.Zero, + bytes, bytes.Length, CFStringEncoding.kCFStringEncodingUTF8, false); + } - private static byte[] GetAccountNameAttributeData(IntPtr itemRef) + private static ICredential CreateCredentialFromAttributes(IntPtr attributes) { - IntPtr tagArrayPtr = IntPtr.Zero; - IntPtr formatArrayPtr = IntPtr.Zero; - IntPtr attrListPtr = IntPtr.Zero; // SecKeychainAttributeList + string service = GetStringAttribute(attributes, kSecAttrService); + string account = GetStringAttribute(attributes, kSecAttrAccount); + string password = GetStringAttribute(attributes, kSecValueData); + string label = GetStringAttribute(attributes, kSecAttrLabel); + return new MacOSKeychainCredential(service, account, password, label); + } - try + private static string GetStringAttribute(IntPtr dict, IntPtr key) + { + if (dict == IntPtr.Zero) { - // Extract the user name by querying for the item's 'account' attribute - tagArrayPtr = Marshal.AllocHGlobal(sizeof(SecKeychainAttrType)); - Marshal.WriteInt32(tagArrayPtr, (int) SecKeychainAttrType.AccountItem); - - formatArrayPtr = Marshal.AllocHGlobal(sizeof(CssmDbAttributeFormat)); - Marshal.WriteInt32(formatArrayPtr, (int) CssmDbAttributeFormat.String); + return null; + } - var attributeInfo = new SecKeychainAttributeInfo + IntPtr buffer = IntPtr.Zero; + try + { + if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero) { - Count = 1, - Tag = tagArrayPtr, - Format = formatArrayPtr, - }; - - ThrowIfError( - SecKeychainItemCopyAttributesAndData( - itemRef, ref attributeInfo, - IntPtr.Zero, out attrListPtr, out _, IntPtr.Zero) - ); - - SecKeychainAttributeList attrList = Marshal.PtrToStructure(attrListPtr); - Debug.Assert(attrList.Count == 1, "Only expecting a list structure containing one attribute to be returned"); - - SecKeychainAttribute attribute = Marshal.PtrToStructure(attrList.Attributes); - - return InteropUtils.ToByteArray(attribute.Data, attribute.Length); + if (CFGetTypeID(value) == CFStringGetTypeID()) + { + int stringLength = (int)CFStringGetLength(value); + int bufferSize = stringLength + 1; + buffer = Marshal.AllocHGlobal(bufferSize); + if (CFStringGetCString(value, buffer, bufferSize, CFStringEncoding.kCFStringEncodingUTF8)) + { + return Marshal.PtrToStringAuto(buffer, stringLength); + } + } + + if (CFGetTypeID(value) == CFDataGetTypeID()) + { + int length = CFDataGetLength(value); + IntPtr ptr = CFDataGetBytePtr(value); + return Marshal.PtrToStringAuto(ptr, length); + } + } } finally { - if (tagArrayPtr != IntPtr.Zero) + if (buffer != IntPtr.Zero) { - Marshal.FreeHGlobal(tagArrayPtr); + Marshal.FreeHGlobal(buffer); } + } - if (formatArrayPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(formatArrayPtr); - } + return null; + } - if (attrListPtr != IntPtr.Zero) - { - SecKeychainItemFreeAttributesAndData(attrListPtr, IntPtr.Zero); - } + private string CreateServiceName(string service) + { + var sb = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(_namespace)) + { + sb.AppendFormat("{0}:", _namespace); } - } - #endregion + sb.Append(service); + return sb.ToString(); + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychainCredential.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychainCredential.cs new file mode 100644 index 000000000..2f8a828fb --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSKeychainCredential.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System.Diagnostics; + +namespace Microsoft.Git.CredentialManager.Interop.MacOS +{ + [DebuggerDisplay("{DebuggerDisplay}")] + public class MacOSKeychainCredential : ICredential + { + internal MacOSKeychainCredential(string service, string account, string password, string label) + { + Service = service; + Account = account; + Password = password; + Label = label; + } + + public string Service { get; } + + public string Account { get; } + + public string Label { get; } + + public string Password { get; } + + private string DebuggerDisplay => $"{Label} [Service: {Service}, Account: {Account}]"; + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/CoreFoundation.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/CoreFoundation.cs index 93cb6e8c0..2c0a16f53 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/CoreFoundation.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/CoreFoundation.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; using System.Runtime.InteropServices; +using static Microsoft.Git.CredentialManager.Interop.MacOS.Native.LibSystem; namespace Microsoft.Git.CredentialManager.Interop.MacOS.Native { @@ -9,7 +10,89 @@ public static class CoreFoundation { private const string CoreFoundationFrameworkLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + public static readonly IntPtr Handle; + public static readonly IntPtr kCFBooleanTrue; + public static readonly IntPtr kCFBooleanFalse; + + static CoreFoundation() + { + Handle = dlopen(CoreFoundationFrameworkLib, 0); + + kCFBooleanTrue = GetGlobal(Handle, "kCFBooleanTrue"); + kCFBooleanFalse = GetGlobal(Handle, "kCFBooleanFalse"); + } + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFArrayCreateMutable(IntPtr allocator, long capacity, IntPtr callbacks); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void CFArrayInsertValueAtIndex(IntPtr theArray, long idx, IntPtr value); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern long CFArrayGetCount(IntPtr theArray); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFArrayGetValueAtIndex(IntPtr theArray, long idx); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFDictionaryCreateMutable( + IntPtr allocator, + long capacity, + IntPtr keyCallBacks, + IntPtr valueCallBacks); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void CFDictionaryAddValue( + IntPtr theDict, + IntPtr key, + IntPtr value); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFDictionaryGetValue(IntPtr theDict, IntPtr key); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern bool CFDictionaryGetValueIfPresent(IntPtr theDict, IntPtr key, out IntPtr value); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, long numBytes, + CFStringEncoding encoding, bool isExternalRepresentation); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern long CFStringGetLength(IntPtr theString); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern bool CFStringGetCString(IntPtr theString, IntPtr buffer, long bufferSize, CFStringEncoding encoding); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern void CFRetain(IntPtr cf); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern void CFRelease(IntPtr cf); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFGetTypeID(IntPtr cf); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFStringGetTypeID(); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFDataGetTypeID(); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFDictionaryGetTypeID(); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFArrayGetTypeID(); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFDataGetBytePtr(IntPtr theData); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFDataGetLength(IntPtr theData); + } + + public enum CFStringEncoding + { + kCFStringEncodingUTF8 = 0x08000100, } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/LibSystem.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/LibSystem.cs new file mode 100644 index 000000000..e4fce903f --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/LibSystem.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Git.CredentialManager.Interop.MacOS.Native +{ + public static class LibSystem + { + private const string LibSystemLib = "/System/Library/Frameworks/System.framework/System"; + + [DllImport(LibSystemLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr dlopen(string name, int flags); + + [DllImport(LibSystemLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr dlsym(IntPtr handle, string symbol); + + public static IntPtr GetGlobal(IntPtr handle, string symbol) + { + IntPtr ptr = dlsym(handle, symbol); + return Marshal.PtrToStructure(ptr); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/SecurityFramework.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/SecurityFramework.cs index adcf7697b..e9a7a5c52 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/SecurityFramework.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/Native/SecurityFramework.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; using System.Runtime.InteropServices; +using static Microsoft.Git.CredentialManager.Interop.MacOS.Native.LibSystem; namespace Microsoft.Git.CredentialManager.Interop.MacOS.Native { @@ -10,9 +11,52 @@ public static class SecurityFramework { private const string SecurityFrameworkLib = "/System/Library/Frameworks/Security.framework/Security"; + public static readonly IntPtr Handle; + public static readonly IntPtr kSecClass; + public static readonly IntPtr kSecMatchLimit; + public static readonly IntPtr kSecReturnAttributes; + public static readonly IntPtr kSecReturnRef; + public static readonly IntPtr kSecReturnPersistentRef; + public static readonly IntPtr kSecClassGenericPassword; + public static readonly IntPtr kSecMatchLimitOne; + public static readonly IntPtr kSecMatchItemList; + public static readonly IntPtr kSecAttrLabel; + public static readonly IntPtr kSecAttrAccount; + public static readonly IntPtr kSecAttrService; + public static readonly IntPtr kSecValueRef; + public static readonly IntPtr kSecValueData; + public static readonly IntPtr kSecReturnData; + + static SecurityFramework() + { + Handle = dlopen(SecurityFrameworkLib, 0); + + kSecClass = GetGlobal(Handle, "kSecClass"); + kSecMatchLimit = GetGlobal(Handle, "kSecMatchLimit"); + kSecReturnAttributes = GetGlobal(Handle, "kSecReturnAttributes"); + kSecReturnRef = GetGlobal(Handle, "kSecReturnRef"); + kSecReturnPersistentRef = GetGlobal(Handle, "kSecReturnPersistentRef"); + kSecClassGenericPassword = GetGlobal(Handle, "kSecClassGenericPassword"); + kSecMatchLimitOne = GetGlobal(Handle, "kSecMatchLimitOne"); + kSecMatchItemList = GetGlobal(Handle, "kSecMatchItemList"); + kSecAttrLabel = GetGlobal(Handle, "kSecAttrLabel"); + kSecAttrAccount = GetGlobal(Handle, "kSecAttrAccount"); + kSecAttrService = GetGlobal(Handle, "kSecAttrService"); + kSecValueRef = GetGlobal(Handle, "kSecValueRef"); + kSecValueData = GetGlobal(Handle, "kSecValueData"); + kSecReturnData = GetGlobal(Handle, "kSecReturnData"); + } + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int SessionGetInfo(int session, out int sessionId, out SessionAttributeBits attributes); + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecAccessCreate(IntPtr descriptor, IntPtr trustedList, out IntPtr accessRef); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemCreateFromContent(IntPtr itemClass, IntPtr attrList, uint length, + IntPtr data, IntPtr keychainRef, IntPtr initialAccess, out IntPtr itemRef); + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int SecKeychainAddGenericPassword( IntPtr keychain, @@ -36,13 +80,13 @@ public static extern int SecKeychainFindGenericPassword( out IntPtr itemRef); [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] - public static extern int SecKeychainItemCopyAttributesAndData( + public static extern unsafe int SecKeychainItemCopyAttributesAndData( IntPtr itemRef, - ref SecKeychainAttributeInfo info, - IntPtr itemClass, // SecItemClass* - out IntPtr attrList, // SecKeychainAttributeList* - out uint dataLength, - IntPtr data); + IntPtr info, + IntPtr itemClass, + SecKeychainAttributeList** attrList, + uint* dataLength, + void** data); [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int SecKeychainItemModifyAttributesAndData( @@ -65,6 +109,16 @@ public static extern int SecKeychainItemFreeAttributesAndData( IntPtr attrList, // SecKeychainAttributeList* IntPtr data); + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecItemCopyMatching(IntPtr query, out IntPtr result); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemCopyFromPersistentReference(IntPtr persistentItemRef, out IntPtr itemRef); + + [DllImport(SecurityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int SecKeychainItemCopyContent(IntPtr itemRef, IntPtr itemClass, IntPtr attrList, + out uint length, out IntPtr outData); + public const int CallerSecuritySession = -1; // https://developer.apple.com/documentation/security/1542001-security_framework_result_codes diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Native/LibGit2.Error.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Native/LibGit2.Error.cs deleted file mode 100644 index b79a04517..000000000 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Native/LibGit2.Error.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. -using System; - -namespace Microsoft.Git.CredentialManager.Interop.Native -{ - public static partial class LibGit2 - { - /// - /// No error. - /// - public const int GIT_OK = 0; - - /// - /// Generic error. - /// - public const int GIT_ERROR = -1; - - /// - /// Requested object could not be found. - /// - public const int GIT_ENOTFOUND = -3; - - /// - /// Object exists preventing operation. - /// - public const int GIT_EEXISTS = -4; - - /// - /// More than one object matches. - /// - public const int GIT_EAMBIGUOUS = -5; - - /// - /// Output buffer too short to hold data. - /// - public const int GIT_EBUFS = -6; - - /// - /// GIT_EUSER is a special error that is never generated by libgit2 - /// code. You can return it from a callback (e.g to stop an iteration) - /// to know that it was generated by the callback and not by libgit2. - /// - public const int GIT_EUSER = -7; - - /// - /// Operation not allowed on bare repository. - /// - public const int GIT_EBAREREPO = -8; - - /// - /// HEAD refers to branch with no commits. - /// - public const int GIT_EUNBORNBRANCH = -9; - - /// - /// Merge in progress prevented operation. - /// - public const int GIT_EUNMERGED = -10; - - /// - /// Reference was not fast-forwardable. - /// - public const int GIT_ENONFASTFORWARD = -11; - - /// - /// Name/ref spec was not in a valid format. - /// - public const int GIT_EINVALIDSPEC = -12; - - /// - /// Checkout conflicts prevented operation. - /// - public const int GIT_ECONFLICT = -13; - - /// - /// Lock file prevented operation. - /// - public const int GIT_ELOCKED = -14; - - /// - /// Reference value does not match expected. - /// - public const int GIT_EMODIFIED = -15; - - /// - /// Authentication error. - /// - public const int GIT_EAUTH = -16; - - /// - /// Server certificate is invalid. - /// - public const int GIT_ECERTIFICATE = -17; - - /// - /// Patch/merge has already been applied. - /// - public const int GIT_EAPPLIED = -18; - - /// - /// The requested peel operation is not possible. - /// - public const int GIT_EPEEL = -19; - - /// - /// Unexpected EOF. - /// - public const int GIT_EEOF = -20; - - /// - /// Invalid operation or input. - /// - public const int GIT_EINVALID = -21; - - /// - /// Uncommitted changes in index prevented operation. - /// - public const int GIT_EUNCOMMITTED = -22; - - /// - /// The operation is not valid for a directory. - /// - public const int GIT_EDIRECTORY = -23; - - /// - /// A merge conflict exists and cannot continue. - /// - public const int GIT_EMERGECONFLICT = -24; - - /// - /// A user-configured callback refused to act. - /// - public const int GIT_PASSTHROUGH = -30; - - /// - /// Signals end of iteration with iterator. - /// - public const int GIT_ITEROVER = -31; - - /// - /// Internal only. - /// - public const int GIT_RETRY = -32; - - /// - /// Hashsum mismatch in object. - /// - public const int GIT_EMISMATCH = -33; - - /// - /// Unsaved changes in the index would be overwritten. - /// - public const int GIT_EINDEXDIRTY = -34; - - /// - /// Patch application failed. - /// - public const int GIT_EAPPLYFAIL = -35; - - public static void ThrowIfError(int result, string functionName = null) - { - if (result != 0) - { - unsafe - { - string mainMessage = functionName is null - ? "libgit2 returned non-zero value" - : $"libgit2 '{functionName}' returned non-zero value"; - - git_error* error = git_error_last(); - - if (error != null && error->message != null) - { - string errorMessage = U8StringConverter.ToManaged(error->message); - - throw new InteropException(mainMessage, result, new Exception(errorMessage)); - } - - throw new InteropException(mainMessage, result); - } - } - } - } -} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Native/LibGit2.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Native/LibGit2.cs deleted file mode 100644 index 06f24e386..000000000 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Native/LibGit2.cs +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. -using System; -using System.Runtime.InteropServices; -using static System.Runtime.InteropServices.CallingConvention; -using static System.Runtime.InteropServices.UnmanagedType; -using static Microsoft.Git.CredentialManager.Interop.U8StringMarshaler; - -namespace Microsoft.Git.CredentialManager.Interop.Native -{ - public static partial class LibGit2 - { - /* - * The CLR will apply the following rules when locating the library on each platform: - * - * Windows - * - Append ".dll" extension - * - * Final name: git2-SHA.dll - * - * macOS (libgit2-SHA.dylib) - * - Prepend the "lib" prefix - * - Append ".dylib" extension - * - * Final name: libgit2-SHA.dylib - * - * Linux (libgit2-SHA.so) - * - Prepend the "lib" prefix - * - Append ".so" extension - * - * Final name: libgit2-SHA.so - */ - private const string LibraryName = "git2-572e4d8"; - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern int git_libgit2_init(); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern int git_libgit2_shutdown(); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe git_error* git_error_last(); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern void git_buf_dispose(git_buf buf); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern int git_repository_discover( - git_buf @out, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string start_path, - bool across_fs, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string ceiling_dirs); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_add_file_ondisk( - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string path, - git_config_level_t level, - git_repository* repo, - int force); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe void git_config_free(git_config* cfg); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_get_string( - [MarshalAs(CustomMarshaler, MarshalCookie = ManagedCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - out string @out, - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string name); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_foreach(git_config* cfg, git_config_foreach_cb callback, void* payload); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_set_string( - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string name, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string value); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_delete_entry( - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string name); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_get_multivar_foreach( - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string name, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string regexp, - git_config_foreach_cb callback, - void *payload); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_set_multivar( - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string name, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string regexp, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string value); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_delete_multivar( - git_config* cfg, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string name, - [MarshalAs(CustomMarshaler, MarshalCookie = NativeCookie, MarshalTypeRef = typeof(U8StringMarshaler))] - string regexp); - - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_open_default(git_config** @out); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_open_level(git_config** @out, git_config* parent, git_config_level_t level); - - [DllImport(LibraryName, CallingConvention = Cdecl)] - public static extern unsafe int git_config_snapshot(git_config** @out, git_config* config); - } - - [UnmanagedFunctionPointer(Cdecl)] - public unsafe delegate int git_config_foreach_cb(git_config_entry entry, void* payload); - - [UnmanagedFunctionPointer(Cdecl)] - public delegate void git_config_entry_free_callback(git_config_entry entry); - - [StructLayout(LayoutKind.Sequential)] - public unsafe struct git_error - { - public byte* message; - public int klass; - } - - [StructLayout(LayoutKind.Sequential)] - public class git_buf - { - public unsafe byte* ptr; - public UIntPtr asize; - public UIntPtr size; - - public override unsafe string ToString() - { - return U8StringConverter.ToManaged(ptr); - } - } - - [StructLayout(LayoutKind.Sequential)] - public class git_config_entry - { - public unsafe byte* name; - public unsafe byte* value; - public uint include_depth; - public git_config_level_t level; - public git_config_entry_free_callback free; - public unsafe void* payload; - - public unsafe string GetName() - { - return U8StringConverter.ToManaged(name); - } - - public unsafe string GetValue() - { - return U8StringConverter.ToManaged(value); - } - } - - public enum git_config_level_t - { - GIT_CONFIG_LEVEL_PROGRAMDATA = 1, - GIT_CONFIG_LEVEL_SYSTEM = 2, - GIT_CONFIG_LEVEL_XDG = 3, - GIT_CONFIG_LEVEL_GLOBAL = 4, - GIT_CONFIG_LEVEL_LOCAL = 5, - GIT_CONFIG_LEVEL_APP = 6, - GIT_CONFIG_HIGHEST_LEVEL = -1, - } - - public struct git_repository { /* opaque */ } - public struct git_config { /* opaque */ } -} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/GpgPassCredentialStore.cs new file mode 100644 index 000000000..51b32c92d --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/GpgPassCredentialStore.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Microsoft.Git.CredentialManager.Interop.Posix +{ + public class GpgPassCredentialStore : PlaintextCredentialStore + { + public const string PasswordStoreDirEnvar = "PASSWORD_STORE_DIR"; + + private readonly IGpg _gpg; + + public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot, string @namespace = null) + : base(fileSystem, storeRoot, @namespace) + { + PlatformUtils.EnsurePosix(); + EnsureArgument.NotNull(gpg, nameof(gpg)); + _gpg = gpg; + } + + protected override string CredentialFileExtension => ".gpg"; + + private string GetGpgId() + { + string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id"); + if (!FileSystem.FileExists(gpgIdPath)) + { + throw new Exception($"Cannot find GPG ID in '{gpgIdPath}'; password store has not been initialized"); + } + + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) + { + return reader.ReadLine(); + } + } + + protected override bool TryDeserializeCredential(string path, out FileCredential credential) + { + string text = _gpg.DecryptFile(path); + + int line1Idx = text.IndexOf(Environment.NewLine, StringComparison.OrdinalIgnoreCase); + if (line1Idx > 0) + { + // Password is the first line + string password = text.Substring(0, line1Idx); + + // All subsequent lines are metadata/attributes + string attrText = text.Substring(line1Idx + Environment.NewLine.Length); + using var attrReader = new StringReader(attrText); + IDictionary attrs = attrReader.ReadDictionary(StringComparer.OrdinalIgnoreCase); + + // Account is optional + attrs.TryGetValue("account", out string account); + + // Service is required + if (attrs.TryGetValue("service", out string service)) + { + credential = new FileCredential(path, service, account, password); + return true; + } + } + + credential = null; + return false; + } + + protected override void SerializeCredential(FileCredential credential) + { + string gpgId = GetGpgId(); + + var sb = new StringBuilder(credential.Password); + sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine); + sb.AppendFormat("account={0}{1}", credential.Account, Environment.NewLine); + string fileContents = sb.ToString(); + + // Ensure the parent directory exists + string parentDir = Path.GetDirectoryName(credential.FullPath); + if (!FileSystem.DirectoryExists(parentDir)) + { + FileSystem.CreateDirectory(parentDir); + } + + // Delete any existing file + if (FileSystem.FileExists(credential.FullPath)) + { + FileSystem.DeleteFile(credential.FullPath); + } + + // Encrypt! + _gpg.EncryptFile(credential.FullPath, gpgId, fileContents); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixEnvironment.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixEnvironment.cs index 38668ea3a..7082a098d 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixEnvironment.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixEnvironment.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; namespace Microsoft.Git.CredentialManager.Interop.Posix { @@ -29,6 +31,36 @@ protected override string[] SplitPathVariable(string value) return value.Split(':'); } + public override string LocateExecutable(string program) + { + const string whichPath = "/usr/bin/which"; + var psi = new ProcessStartInfo(whichPath, program) + { + UseShellExecute = false, + RedirectStandardOutput = true + }; + + using (var where = new Process {StartInfo = psi}) + { + where.Start(); + where.WaitForExit(); + + if (where.ExitCode != 0) + { + throw new Exception($"Failed to locate '{program}' using {whichPath}. Exit code: {where.ExitCode}."); + } + + string stdout = where.StandardOutput.ReadToEnd(); + if (string.IsNullOrWhiteSpace(stdout)) + { + return null; + } + + string[] results = stdout.Split(new[] {'\n'}, StringSplitOptions.RemoveEmptyEntries); + return results.FirstOrDefault(); + } + } + #endregion private static IReadOnlyDictionary GetCurrentVariables() diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixFileSystem.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixFileSystem.cs new file mode 100644 index 000000000..2522a3a83 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixFileSystem.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.Git.CredentialManager.Interop.Posix +{ + public abstract class PosixFileSystem : FileSystem + { + } +} \ No newline at end of file diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixSessionManager.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixSessionManager.cs index 66e974b00..8e37552e8 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixSessionManager.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Posix/PosixSessionManager.cs @@ -6,7 +6,7 @@ namespace Microsoft.Git.CredentialManager.Interop.Posix { public class PosixSessionManager : ISessionManager { - protected PosixSessionManager() + public PosixSessionManager() { PlatformUtils.EnsurePosix(); } diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs index 13b79b8bf..971599bcb 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. using System; using System.Runtime.InteropServices; -using System.Runtime.InteropServices.ComTypes; using FILETIME = System.Runtime.InteropServices.ComTypes.FILETIME; namespace Microsoft.Git.CredentialManager.Interop.Windows.Native @@ -31,8 +30,15 @@ public static extern bool CredDelete( int flags); [DllImport(LibraryName, EntryPoint = "CredFree", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern void CredFree( + public static extern void CredFree( IntPtr credential); + + [DllImport(LibraryName, EntryPoint = "CredEnumerateW", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredEnumerate( + string filter, + CredentialEnumerateFlags flags, + out int count, + out IntPtr credentialsList); } public enum CredentialType @@ -50,6 +56,13 @@ public enum CredentialPersist Enterprise = 3, } + [Flags] + public enum CredentialEnumerateFlags + { + None = 0, + AllCredentials = 0x1 + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct Win32Credential { @@ -64,7 +77,7 @@ public struct Win32Credential public IntPtr CredentialBlob; public CredentialPersist Persist; public int AttributeCount; - public IntPtr CredAttribute; + public IntPtr Attributes; [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; [MarshalAs(UnmanagedType.LPWStr)] diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredential.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredential.cs new file mode 100644 index 000000000..c52f59dd9 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredential.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.Git.CredentialManager.Interop.Windows +{ + public class WindowsCredential : ICredential + { + public WindowsCredential(string service, string userName, string password, string targetName) + { + Service = service; + UserName = userName; + Password = password; + TargetName = targetName; + } + + public string Service { get; } + + public string UserName { get; } + + public string Password { get; } + + public string TargetName { get; } + + string ICredential.Account => UserName; + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredentialManager.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredentialManager.cs index 5ef8ddc74..7c231eba2 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredentialManager.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsCredentialManager.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; +using System.Collections.Generic; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using Microsoft.Git.CredentialManager.Interop.Windows.Native; @@ -9,119 +11,326 @@ namespace Microsoft.Git.CredentialManager.Interop.Windows { public class WindowsCredentialManager : ICredentialStore { + private const string TargetNameLegacyGenericPrefix = "LegacyGeneric:target="; + + private readonly string _namespace; + #region Constructors /// /// Open the Windows Credential Manager vault for the current user. /// + /// Optional namespace to scope credential operations. /// Current user's Credential Manager vault. - public static WindowsCredentialManager Open() - { - return new WindowsCredentialManager(); - } - - private WindowsCredentialManager() + public WindowsCredentialManager(string @namespace = null) { PlatformUtils.EnsureWindows(); + _namespace = @namespace; } #endregion #region ICredentialStore - public ICredential Get(string key) + public ICredential Get(string service, string account) + { + return Enumerate(service, account).FirstOrDefault(); + } + + public void AddOrUpdate(string service, string account, string secret) { - IntPtr credPtr = IntPtr.Zero; + EnsureArgument.NotNullOrWhiteSpace(service, nameof(service)); + + IntPtr existingCredPtr = IntPtr.Zero; + IntPtr credBlob = IntPtr.Zero; try { + // Determine if we need to update an existing credential, which might have + // a target name that does not include the account name. + // + // We first check for the presence of a credential with an account-less + // target name. + // + // - If such credential exists and *has the same account* then we will + // update that entry. + // - If such credential exists and does *not* have the same account then + // we must create a new entry with the account in the target name. + // - If no such credential exists then we create a new entry with the + // account-less target name. + // + string targetName = CreateTargetName(service, account: null); + if (Advapi32.CredRead(targetName, CredentialType.Generic, 0, out existingCredPtr)) + { + var existingCred = Marshal.PtrToStructure(existingCredPtr); + if (!StringComparer.Ordinal.Equals(existingCred.UserName, account)) + { + // Create new entry with the account in the target name + targetName = CreateTargetName(service, account); + } + } + + byte[] secretBytes = Encoding.Unicode.GetBytes(secret); + credBlob = Marshal.AllocHGlobal(secretBytes.Length); + Marshal.Copy(secretBytes, 0, credBlob, secretBytes.Length); + + var newCred = new Win32Credential + { + Type = CredentialType.Generic, + TargetName = targetName, + CredentialBlobSize = secretBytes.Length, + CredentialBlob = credBlob, + Persist = CredentialPersist.LocalMachine, + UserName = account, + }; + + int result = Win32Error.GetLastError( - Advapi32.CredRead(key, CredentialType.Generic, 0, out credPtr) + Advapi32.CredWrite(ref newCred, 0) + ); + + Win32Error.ThrowIfError(result, "Failed to write item to store."); + } + finally + { + if (credBlob != IntPtr.Zero) + { + Marshal.FreeHGlobal(credBlob); + } + + if (existingCredPtr != IntPtr.Zero) + { + Advapi32.CredFree(existingCredPtr); + } + } + } + + public bool Remove(string service, string account) + { + WindowsCredential credential = Enumerate(service, account).FirstOrDefault(); + + if (credential != null) + { + int result = Win32Error.GetLastError( + Advapi32.CredDelete(credential.TargetName, CredentialType.Generic, 0) ); switch (result) { case Win32Error.Success: - Win32Credential credential = Marshal.PtrToStructure(credPtr); + return true; + + case Win32Error.NotFound: + return false; + + default: + Win32Error.ThrowIfError(result); + return false; + } + } - var userName = credential.UserName; + return false; + } + + #endregion + + private IEnumerable Enumerate(string service, string account) + { + IntPtr credList = IntPtr.Zero; + + try + { + int result = Win32Error.GetLastError( + Advapi32.CredEnumerate( + null, + CredentialEnumerateFlags.AllCredentials, + out int count, + out credList) + ); + + switch (result) + { + case Win32Error.Success: + int ptrSize = Marshal.SizeOf(); + for (int i = 0; i < count; i++) + { + IntPtr credPtr = Marshal.ReadIntPtr(credList, i * ptrSize); + Win32Credential credential = Marshal.PtrToStructure(credPtr); - byte[] passwordBytes = InteropUtils.ToByteArray(credential.CredentialBlob, credential.CredentialBlobSize); - var password = Encoding.Unicode.GetString(passwordBytes); + if (!IsMatch(service, account, credential)) + { + continue; + } - return new GitCredential(userName, password); + yield return CreateCredentialFromStructure(credential); + } + break; case Win32Error.NotFound: - return null; + yield break; default: - Win32Error.ThrowIfError(result, "Failed to read item from store."); - return null; + Win32Error.ThrowIfError(result, "Failed to enumerate credentials."); + yield break; } } finally { - if (credPtr != IntPtr.Zero) + if (credList != IntPtr.Zero) { - Advapi32.CredFree(credPtr); + Advapi32.CredFree(credList); } } } - public void AddOrUpdate(string key, ICredential credential) + private WindowsCredential CreateCredentialFromStructure(Win32Credential credential) { - byte[] passwordBytes = Encoding.Unicode.GetBytes(credential.Password); - - var w32Credential = new Win32Credential + string password = null; + if (credential.CredentialBlobSize != 0 && credential.CredentialBlob != IntPtr.Zero) { - Type = CredentialType.Generic, - TargetName = key, - CredentialBlob = Marshal.AllocHGlobal(passwordBytes.Length), - CredentialBlobSize = passwordBytes.Length, - Persist = CredentialPersist.LocalMachine, - AttributeCount = 0, - UserName = credential.UserName, - }; + byte[] passwordBytes = InteropUtils.ToByteArray( + credential.CredentialBlob, + credential.CredentialBlobSize); + password = Encoding.Unicode.GetString(passwordBytes); + } - try + // Recover the target name we gave from the internal (raw) target name + string targetName = credential.TargetName.TrimUntilIndexOf(TargetNameLegacyGenericPrefix); + + // Recover the service name from the target name + string serviceName = targetName; + if (!string.IsNullOrWhiteSpace(_namespace)) { - Marshal.Copy(passwordBytes, 0, w32Credential.CredentialBlob, passwordBytes.Length); + serviceName = serviceName.TrimUntilIndexOf($"{_namespace}:"); + } - int result = Win32Error.GetLastError( - Advapi32.CredWrite(ref w32Credential, 0) - ); + // Strip any userinfo component from the service name + serviceName = RemoveUriUserInfo(serviceName); - Win32Error.ThrowIfError(result, "Failed to write item to store."); - } - finally + return new WindowsCredential(serviceName, credential.UserName, password, targetName); + } + + public /* for testing */ static string RemoveUriUserInfo(string url) + { + // To remove the userinfo component we must search for the end of the :// scheme + // delimiter, and the start of the @ userinfo delimiter. We don't want to match + // any other '@' character however (such as one in the URI path). + // To ensure this we only consider an '@' character that exists before the first + // '/' character after the scheme delimiter - that is to say the authority-path + // separator. + // + // authority + // |-----------| + // scheme://userinfo@host/path + // + int schemeDelimIdx = url.IndexOf(Uri.SchemeDelimiter, StringComparison.Ordinal); + if (schemeDelimIdx > 0) { - if (w32Credential.CredentialBlob != IntPtr.Zero) + int authorityIdx = schemeDelimIdx + Uri.SchemeDelimiter.Length; + int slashIdx = url.IndexOf("/", authorityIdx, StringComparison.Ordinal); + int atIdx = url.IndexOf("@", StringComparison.Ordinal); + + // No path component or trailing slash; use end of string + if (slashIdx < 0) { - Marshal.FreeHGlobal(w32Credential.CredentialBlob); + slashIdx = url.Length - 1; + } + + // Only if the '@' is before the first slash is this the userinfo delimiter + if (0 < atIdx && atIdx < slashIdx) + { + return url.Substring(0, authorityIdx) + url.Substring(atIdx + 1); } } + + return url; } - public bool Remove(string key) + private bool IsMatch(string service, string account, Win32Credential credential) { - int result = Win32Error.GetLastError( - Advapi32.CredDelete(key, CredentialType.Generic, 0) - ); + // Match against the username first + if (!string.IsNullOrWhiteSpace(account) && + !StringComparer.Ordinal.Equals(account, credential.UserName)) + { + return false; + } - switch (result) + // Trim the "LegacyGeneric" prefix Windows adds and any namespace we have been filtered with + string targetName = credential.TargetName.TrimUntilIndexOf(TargetNameLegacyGenericPrefix); + if (!string.IsNullOrWhiteSpace(_namespace)) { - case Win32Error.Success: - return true; + targetName = targetName.TrimUntilIndexOf($"{_namespace}:"); + } - case Win32Error.NotFound: + // If the target name matches the service name exactly then return 'match' + if (StringComparer.Ordinal.Equals(service, targetName)) + { + return true; + } + + // Try matching the target and service as URIs + if (Uri.TryCreate(service, UriKind.Absolute, out Uri serviceUri) && + Uri.TryCreate(targetName, UriKind.Absolute, out Uri targetUri)) + { + // Match host name + if (!StringComparer.OrdinalIgnoreCase.Equals(serviceUri.Host, targetUri.Host)) + { return false; + } - default: - Win32Error.ThrowIfError(result); + // Match port number + if (!serviceUri.IsDefaultPort && serviceUri.Port == targetUri.Port) + { return false; + } + + // Match path + if (!string.IsNullOrWhiteSpace(serviceUri.AbsolutePath) && + !StringComparer.OrdinalIgnoreCase.Equals(serviceUri.AbsolutePath, targetUri.AbsolutePath)) + { + return false; + } + + // URLs match + return true; } + + // Unable to match + return false; } - #endregion + private string CreateTargetName(string service, string account) + { + var serviceUri = new Uri(service, UriKind.Absolute); + var sb = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(_namespace)) + { + sb.AppendFormat("{0}:", _namespace); + } + + if (!string.IsNullOrWhiteSpace(serviceUri.Scheme)) + { + sb.AppendFormat("{0}://", serviceUri.Scheme); + } + + if (!string.IsNullOrWhiteSpace(account)) + { + string escapedAccount = account.Replace('@', '_'); + sb.AppendFormat("{0}@", escapedAccount); + } + + if (!string.IsNullOrWhiteSpace(serviceUri.Host)) + { + sb.Append(serviceUri.Host); + } + + if (!string.IsNullOrWhiteSpace(serviceUri.AbsolutePath.TrimEnd('/'))) + { + sb.Append(serviceUri.AbsolutePath); + } + + return sb.ToString(); + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsEnvironment.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsEnvironment.cs index b9adc0cbe..919a48192 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsEnvironment.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsEnvironment.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -64,6 +65,36 @@ public override void RemoveDirectoryFromPath(string directoryPath, EnvironmentVa } } + public override string LocateExecutable(string program) + { + string wherePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "where.exe"); + var psi = new ProcessStartInfo(wherePath, program) + { + UseShellExecute = false, + RedirectStandardOutput = true + }; + + using (var where = new Process {StartInfo = psi}) + { + where.Start(); + where.WaitForExit(); + + if (where.ExitCode != 0) + { + throw new Exception($"Failed to locate '{program}' using where.exe. Exit code: {where.ExitCode}."); + } + + string stdout = where.StandardOutput.ReadToEnd(); + if (string.IsNullOrWhiteSpace(stdout)) + { + return null; + } + + string[] results = stdout.Split(new[] {'\r', '\n'}, StringSplitOptions.RemoveEmptyEntries); + return results.FirstOrDefault(); + } + } + #endregion private static IReadOnlyDictionary GetCurrentVariables() diff --git a/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj b/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj index fe627f76d..2aa31d953 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj +++ b/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj @@ -16,7 +16,6 @@ - diff --git a/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs b/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs new file mode 100644 index 000000000..954f2df7e --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/PlaintextCredentialStore.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Microsoft.Git.CredentialManager +{ + public class PlaintextCredentialStore : ICredentialStore + { + public PlaintextCredentialStore(IFileSystem fileSystem, string storeRoot, string @namespace = null) + { + EnsureArgument.NotNull(fileSystem, nameof(fileSystem)); + EnsureArgument.NotNullOrWhiteSpace(storeRoot, nameof(storeRoot)); + + FileSystem = fileSystem; + StoreRoot = storeRoot; + Namespace = @namespace; + } + + protected IFileSystem FileSystem { get; } + protected string StoreRoot { get; } + protected string Namespace { get; } + protected virtual string CredentialFileExtension => ".credential"; + + #region ICredentialStore + + public ICredential Get(string service, string account) + { + string serviceSlug = CreateServiceSlug(service); + string searchPath = Path.Combine(StoreRoot, serviceSlug); + bool anyAccount = string.IsNullOrWhiteSpace(account); + + if (!FileSystem.DirectoryExists(searchPath)) + { + return null; + } + + IEnumerable allFiles = FileSystem.EnumerateFiles(searchPath, $"*{CredentialFileExtension}"); + + foreach (string fullPath in allFiles) + { + string accountFile = Path.GetFileNameWithoutExtension(fullPath); + if (anyAccount || StringComparer.OrdinalIgnoreCase.Equals(account, accountFile)) + { + // Validate the credential metadata also matches our search + if (TryDeserializeCredential(fullPath, out FileCredential credential) && + StringComparer.OrdinalIgnoreCase.Equals(service, credential.Service) && + (anyAccount || StringComparer.OrdinalIgnoreCase.Equals(account, credential.Account))) + { + return credential; + } + } + } + + return null; + } + + public void AddOrUpdate(string service, string account, string secret) + { + string serviceSlug = CreateServiceSlug(service); + string servicePath = Path.Combine(StoreRoot, serviceSlug); + + if (!FileSystem.DirectoryExists(servicePath)) + { + FileSystem.CreateDirectory(servicePath); + } + + string fullPath = Path.Combine(servicePath, $"{account}{CredentialFileExtension}"); + var credential = new FileCredential(fullPath, service, account, secret); + SerializeCredential(credential); + } + + public bool Remove(string service, string account) + { + string serviceSlug = CreateServiceSlug(service); + string searchPath = Path.Combine(StoreRoot, serviceSlug); + bool anyAccount = string.IsNullOrWhiteSpace(account); + + if (!FileSystem.DirectoryExists(searchPath)) + { + return false; + } + + IEnumerable allFiles = FileSystem.EnumerateFiles(searchPath, $"*{CredentialFileExtension}"); + + foreach (string fullPath in allFiles) + { + string accountFile = Path.GetFileNameWithoutExtension(fullPath); + if (anyAccount || StringComparer.OrdinalIgnoreCase.Equals(account, accountFile)) + { + // Validate the credential metadata also matches our search + if (TryDeserializeCredential(fullPath, out FileCredential credential) && + StringComparer.OrdinalIgnoreCase.Equals(service, credential.Service) && + (anyAccount || StringComparer.OrdinalIgnoreCase.Equals(account, credential.Account))) + { + // Delete the credential file + FileSystem.DeleteFile(fullPath); + return true; + } + } + } + + return false; + } + + #endregion + + protected virtual bool TryDeserializeCredential(string path, out FileCredential credential) + { + string text; + using (var stream = FileSystem.OpenFileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) + { + text = reader.ReadToEnd(); + } + + int line1Idx = text.IndexOf(Environment.NewLine, StringComparison.OrdinalIgnoreCase); + if (line1Idx > 0) + { + // Password is the first line + string password = text.Substring(0, line1Idx); + + // All subsequent lines are metadata/attributes + string attrText = text.Substring(line1Idx + Environment.NewLine.Length); + using var attrReader = new StringReader(attrText); + IDictionary attrs = attrReader.ReadDictionary(StringComparer.OrdinalIgnoreCase); + + // Account is optional + attrs.TryGetValue("account", out string account); + + // Service is required + if (attrs.TryGetValue("service", out string service)) + { + credential = new FileCredential(path, service, account, password); + return true; + } + } + + credential = null; + return false; + } + + protected virtual void SerializeCredential(FileCredential credential) + { + // Ensure the parent directory exists + string parentDir = Path.GetDirectoryName(credential.FullPath); + if (!FileSystem.DirectoryExists(parentDir)) + { + FileSystem.CreateDirectory(parentDir); + } + + using (var stream = FileSystem.OpenFileStream(credential.FullPath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var writer = new StreamWriter(stream)) + { + writer.WriteLine(credential.Password); + writer.WriteLine("service={0}", credential.Service); + writer.WriteLine("account={0}", credential.Account); + writer.Flush(); + } + } + + private string CreateServiceSlug(string service) + { + var sb = new StringBuilder(); + char sep = Path.DirectorySeparatorChar; + + if (!string.IsNullOrWhiteSpace(Namespace)) + { + sb.AppendFormat("{0}{1}", Namespace, sep); + } + + if (Uri.TryCreate(service, UriKind.Absolute, out Uri serviceUri)) + { + sb.AppendFormat("{0}{1}", serviceUri.Scheme, sep); + sb.AppendFormat("{0}", serviceUri.Host); + + if (!serviceUri.IsDefaultPort) + { + sb.Append(PlatformUtils.IsWindows() ? '-' : ':'); + sb.Append(serviceUri.Port); + } + + sb.Append(serviceUri.AbsolutePath.Replace('/', sep)); + } + else + { + sb.Append(service); + } + + return sb.ToString(); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Settings.cs b/src/shared/Microsoft.Git.CredentialManager/Settings.cs index 2784fc65e..211eae153 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Settings.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Settings.cs @@ -36,11 +36,6 @@ public interface ISettings : IDisposable /// All values for the specified setting, in order of precedence, or an empty collection if no such values are set. IEnumerable GetSettingValues(string envarName, string section, string property); - /// - /// Git repository that local configuration lookup is scoped to, or null if this instance is not scoped to a repository. - /// - string RepositoryPath { get; } - /// /// Git remote address that setting lookup is scoped to, or null if no remote URL has been discovered. /// @@ -114,6 +109,17 @@ public interface ISettings : IDisposable /// /// This value is platform specific. string ParentWindowId { get; } + + /// + /// Credential storage namespace prefix. + /// + /// The default value is "git" if unset. + string CredentialNamespace { get; } + + /// + /// Credential backing store override. + /// + string CredentialBackingStore { get; } } public class Settings : ISettings @@ -121,16 +127,13 @@ public class Settings : ISettings private readonly IEnvironment _environment; private readonly IGit _git; - private IGitConfiguration _gitConfig; - - public Settings(IEnvironment environment, IGit git, string repositoryPath = null) + public Settings(IEnvironment environment, IGit git) { EnsureArgument.NotNull(environment, nameof(environment)); EnsureArgument.NotNull(git, nameof(git)); _environment = environment; _git = git; - RepositoryPath = repositoryPath; } public bool TryGetSetting(string envarName, string section, string property, out string value) @@ -156,7 +159,7 @@ public IEnumerable GetSettingValues(string envarName, string section, st if (section != null && property != null) { - IGitConfiguration config = GetGitConfiguration(); + IGitConfiguration config = _git.GetConfiguration(); if (RemoteUri != null) { @@ -251,8 +254,6 @@ public IEnumerable GetSettingValues(string envarName, string section, st } } - public string RepositoryPath { get; } - public Uri RemoteUri { get; set; } public bool IsDebuggingEnabled => _environment.Variables.GetBooleanyOrDefault(KnownEnvars.GcmDebug, false); @@ -416,13 +417,30 @@ bool TryGetUriSetting(string envarName, string section, string property, out Uri return null; } - public string ParentWindowId => _environment.Variables.TryGetValue(Constants.EnvironmentVariables.GcmParentWindow, out string parentWindowId) ? parentWindowId : null; + public string ParentWindowId => _environment.Variables.TryGetValue(KnownEnvars.GcmParentWindow, out string parentWindowId) ? parentWindowId : null; - private IGitConfiguration GetGitConfiguration() => _gitConfig ?? (_gitConfig = _git.GetConfiguration(RepositoryPath)); + public string CredentialNamespace => + TryGetSetting(KnownEnvars.GcmCredNamespace, + KnownGitCfg.Credential.SectionName, KnownGitCfg.Credential.CredNamespace, + out string @namespace) + ? @namespace + : Constants.DefaultCredentialNamespace; + + public string CredentialBackingStore => + TryGetSetting( + KnownEnvars.GcmCredentialStore, + KnownGitCfg.Credential.SectionName, + KnownGitCfg.Credential.CredentialStore, + out string credStore) + ? credStore + : null; #region IDisposable - public void Dispose() => _gitConfig?.Dispose(); + public void Dispose() + { + // Do nothing + } #endregion } diff --git a/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs b/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs index bbd08c4b7..ea0afb3b7 100644 --- a/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs +++ b/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs @@ -1,115 +1,108 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System; -using System.Collections; using System.Collections.Generic; +using System.Linq; namespace Microsoft.Git.CredentialManager.Tests.Objects { - public class TestCredentialStore : ICredentialStore, IDictionary + public class TestCredentialStore : ICredentialStore { - private readonly IDictionary _store; + private readonly IDictionary<(string service, string account), TestCredential> _store; public TestCredentialStore() { - _store = new Dictionary(StringComparer.Ordinal); + _store = new Dictionary<(string,string), TestCredential>(); } #region ICredentialStore - ICredential ICredentialStore.Get(string key) + ICredential ICredentialStore.Get(string service, string account) { - if (_store.TryGetValue(key, out var credential)) - { - return credential; - } - - return null; - } - - void ICredentialStore.AddOrUpdate(string key, ICredential credential) - { - _store[key] = credential; - } - - public void Add(string key, ICredential value) - { - _store.Add(key, value); - } - - public bool ContainsKey(string key) - { - return _store.ContainsKey(key); + return TryGet(service, account, out TestCredential credential) ? credential : null; } - public bool Remove(string key) + void ICredentialStore.AddOrUpdate(string service, string account, string secret) { - return _store.Remove(key); + Add(service, account, secret); } - public bool TryGetValue(string key, out ICredential value) + bool ICredentialStore.Remove(string service, string account) { - return _store.TryGetValue(key, out value); - } - - public ICredential this[string key] - { - get => _store[key]; - set => _store[key] = value; - } - - public ICollection Keys => _store.Keys; - - public ICollection Values => _store.Values; + foreach (var key in _store.Keys) + { + if ((service == null || key.service == service) && + (account == null || key.account == account)) + { + _store.Remove(key); + return true; + } + } - bool ICredentialStore.Remove(string key) - { - return _store.Remove(key); + return false; } #endregion - #region IDictionary + public int Count => _store.Count; - public IEnumerator> GetEnumerator() + public bool TryGet(string service, string account, out TestCredential credential) { - return _store.GetEnumerator(); + credential = Query(service, account).FirstOrDefault(); + return credential != null; } - IEnumerator IEnumerable.GetEnumerator() + public void Add(string service, TestCredential credential) { - return ((IEnumerable) _store).GetEnumerator(); + _store[(service, credential.Account)] = credential; } - public void Add(KeyValuePair item) + public TestCredential Add(string service, string account, string secret) { - _store.Add(item); + var credential = new TestCredential(service, account, secret); + _store[(service, account)] = credential; + return credential; } - public void Clear() + public bool Contains(string service, string account) { - _store.Clear(); + return TryGet(service, account, out _); } - public bool Contains(KeyValuePair item) + private IEnumerable Query(string service, string account) { - return _store.Contains(item); - } + if (string.IsNullOrWhiteSpace(account)) + { + // Find the all credentials matching service + foreach (var kvp in _store) + { + if (kvp.Key.service == service) + { + yield return kvp.Value; + } + } + } - public void CopyTo(KeyValuePair[] array, int arrayIndex) - { - _store.CopyTo(array, arrayIndex); + // Find the specific credential matching both service and credential + if (_store.TryGetValue((service, account), out var credential)) + { + yield return credential; + } } + } - public bool Remove(KeyValuePair item) + public class TestCredential : ICredential + { + public TestCredential(string service, string account, string password) { - return _store.Remove(item); + Service = service; + Account = account; + Password = password; } - public int Count => _store.Count; + public string Service { get; } - public bool IsReadOnly => _store.IsReadOnly; + public string Account { get; } - #endregion + public string Password { get; } } } diff --git a/src/shared/TestInfrastructure/Objects/TestEnvironment.cs b/src/shared/TestInfrastructure/Objects/TestEnvironment.cs index 4739bedc3..ab2ff4e12 100644 --- a/src/shared/TestInfrastructure/Objects/TestEnvironment.cs +++ b/src/shared/TestInfrastructure/Objects/TestEnvironment.cs @@ -80,6 +80,23 @@ public void RemoveDirectoryFromPath(string directoryPath, EnvironmentVariableTar Variables["PATH"] = string.Join(_envPathSeparator, Path); } + public string LocateExecutable(string program) + { + if (WhichFiles.TryGetValue(program, out ICollection paths)) + { + return paths.FirstOrDefault(); + } + + if (!System.IO.Path.HasExtension(program) && PlatformUtils.IsWindows()) + { + // If we're testing on a Windows platform, don't have a file extension, and were unable to locate + // the executable file.. try appending .exe. + return WhichFiles.TryGetValue($"{program}.exe", out paths) ? paths.FirstOrDefault() : null; + } + + return null; + } + #endregion } } diff --git a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs index ef71a70f3..c2f41eadb 100644 --- a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs +++ b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs @@ -3,21 +3,24 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; namespace Microsoft.Git.CredentialManager.Tests.Objects { public class TestFileSystem : IFileSystem { - public IDictionary Files { get; set; } = new Dictionary(); + public IDictionary Files { get; set; } = new Dictionary(); public ISet Directories { get; set; } = new HashSet(); public string CurrentDirectory { get; set; } = Path.GetTempPath(); - public IEqualityComparer PathComparer { get; set; }= StringComparer.OrdinalIgnoreCase; + public bool IsCaseSensitive { get; set; } = false; #region IFileSystem bool IFileSystem.IsSamePath(string a, string b) { - return PathComparer.Equals(a, b); + return IsCaseSensitive + ? StringComparer.Ordinal.Equals(a, b) + : StringComparer.OrdinalIgnoreCase.Equals(a, b); } bool IFileSystem.FileExists(string path) @@ -37,9 +40,69 @@ string IFileSystem.GetCurrentDirectory() Stream IFileSystem.OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare) { - return Files[path]; + bool writable = fileAccess != FileAccess.Read; + + if (fileMode == FileMode.Create) + { + return new TestFileStream(this, path); + } + + return new MemoryStream(Files[path], writable); + } + + void IFileSystem.CreateDirectory(string path) + { + Directories.Add(path); + } + + void IFileSystem.DeleteFile(string path) + { + Files.Remove(path); + } + + IEnumerable IFileSystem.EnumerateFiles(string path, string searchPattern) + { + bool IsPatternMatch(string s, string p) + { + var options = IsCaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase; + string regex = p + .Replace(".", "\\.") + .Replace("*", ".*"); + + return Regex.IsMatch(s, regex, options); + } + + StringComparison comparer = IsCaseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + foreach (var filePath in Files.Keys) + { + if (filePath.StartsWith(path, comparer) && IsPatternMatch(filePath, searchPattern)) + { + yield return filePath; + } + } } #endregion } + + public class TestFileStream : MemoryStream + { + private readonly TestFileSystem _fs; + private readonly string _path; + + public TestFileStream(TestFileSystem fs, string path) + { + _fs = fs; + _path = path; + } + + public override void Flush() + { + base.Flush(); + _fs.Files[_path] = base.ToArray(); + } + } } diff --git a/src/shared/TestInfrastructure/Objects/TestGit.cs b/src/shared/TestInfrastructure/Objects/TestGit.cs index ec53761ab..45776728e 100644 --- a/src/shared/TestInfrastructure/Objects/TestGit.cs +++ b/src/shared/TestInfrastructure/Objects/TestGit.cs @@ -2,46 +2,42 @@ // Licensed under the MIT license. using System; using System.Collections.Generic; -using System.Linq; namespace Microsoft.Git.CredentialManager.Tests.Objects { public class TestGit : IGit { + public TestGitConfiguration SystemConfiguration { get; } = new TestGitConfiguration(); public TestGitConfiguration GlobalConfiguration { get; } = new TestGitConfiguration(); - - public IDictionary Repositories { get; } = new Dictionary(); - - public TestGitRepository AddRepository(TestGitRepository repo) - { - Repositories.Add(repo.Path, repo); - return repo; - } - - public TestGitRepository AddRepository(string repoPath, TestGitConfiguration repoConfig) => - AddRepository(new TestGitRepository(repoPath, repoConfig)); - - public TestGitRepository AddRepository(string repoPath) => - AddRepository(repoPath, new TestGitConfiguration()); + public TestGitConfiguration LocalConfiguration { get; } = new TestGitConfiguration(); #region IGit - void IDisposable.Dispose() { } - - IGitConfiguration IGit.GetConfiguration(string repositoryPath) + IGitConfiguration IGit.GetConfiguration(GitConfigurationLevel level) { - if (string.IsNullOrWhiteSpace(repositoryPath) || !Repositories.TryGetValue(repositoryPath, out TestGitRepository repo)) + switch (level) { - return GlobalConfiguration; + case GitConfigurationLevel.All: + IDictionary> mergedConfigDict = + MergeDictionaries( + SystemConfiguration.Dictionary, + GlobalConfiguration.Dictionary, + LocalConfiguration.Dictionary); + return new TestGitConfiguration(mergedConfigDict); + case GitConfigurationLevel.ProgramData: + case GitConfigurationLevel.Xdg: + return new TestGitConfiguration(); + case GitConfigurationLevel.System: + return SystemConfiguration; + case GitConfigurationLevel.Global: + return GlobalConfiguration; + case GitConfigurationLevel.Local: + return LocalConfiguration; + default: + throw new ArgumentOutOfRangeException(nameof(level), level, $"Unknown {nameof(GitConfigurationLevel)}"); } - - IDictionary> mergedConfigDict = MergeDictionaries(GlobalConfiguration.Dictionary, repo.Configuration.Dictionary); - - return new TestGitConfiguration(mergedConfigDict); } - string IGit.GetRepositoryPath(string path) => Repositories.Keys.FirstOrDefault(path.StartsWith); - #endregion private static IDictionary> MergeDictionaries(params IDictionary>[] dictionaries) @@ -58,18 +54,5 @@ private static IDictionary> MergeDictionaries(params IDict return result; } - - public class TestGitRepository - { - internal TestGitRepository(string path, TestGitConfiguration configuration) - { - Path = path; - Configuration = configuration ?? new TestGitConfiguration(); - } - - public string Path { get; } - - public TestGitConfiguration Configuration { get; } - } } } diff --git a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs index 8f0445488..47173a58e 100644 --- a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs +++ b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs @@ -45,16 +45,11 @@ public void Enumerate(GitConfigurationEnumerationCallback cb) } } - public IGitConfiguration GetFilteredConfiguration(GitConfigurationLevel level) - { - return this; - } - public bool TryGetValue(string name, out string value) { if (Dictionary.TryGetValue(name, out var values)) { - // Simulate libgit2 + // TODO: simulate git if (values.Count > 1) { throw new Exception("Configuration entry is a multivar"); @@ -79,7 +74,7 @@ public void SetValue(string name, string value) Dictionary[name] = values; } - // Simulate libgit2 + // TODO: simulate git if (values.Count > 1) { throw new Exception("Configuration entry is a multivar"); @@ -95,9 +90,9 @@ public void SetValue(string name, string value) } } - public void DeleteEntry(string name) + public void Unset(string name) { - // Simulate libgit2 + // TODO: simulate git if (Dictionary.TryGetValue(name, out var values) && values.Count > 1) { throw new Exception("Configuration entry is a multivar"); @@ -106,29 +101,29 @@ public void DeleteEntry(string name) Dictionary.Remove(name); } - public IEnumerable GetMultivarValue(string name, string regexp) + public IEnumerable GetRegex(string nameRegex, string valueRegex) { - if (Dictionary.TryGetValue(name, out IList values)) + if (Dictionary.TryGetValue(nameRegex, out IList values)) { - return values.Where(x => Regex.IsMatch(x, regexp)); + return values.Where(x => Regex.IsMatch(x, valueRegex)); } return Enumerable.Empty(); } - public void SetMultivarValue(string name, string regexp, string value) + public void ReplaceAll(string nameRegex, string valueRegex, string value) { - if (!Dictionary.TryGetValue(name, out IList values)) + if (!Dictionary.TryGetValue(nameRegex, out IList values)) { values = new List(); - Dictionary[name] = values; + Dictionary[nameRegex] = values; } bool updated = false; for (int i = 0; i < values.Count; i++) { // Update matching values - if (Regex.IsMatch(values[i], regexp)) + if (Regex.IsMatch(values[i], valueRegex)) { values[i] = value; updated = true; @@ -142,14 +137,14 @@ public void SetMultivarValue(string name, string regexp, string value) } } - public void DeleteMultivarEntry(string name, string regexp) + public void UnsetAll(string name, string valueRegex) { if (Dictionary.TryGetValue(name, out IList values)) { for (int i = 0; i < values.Count;) { // Remove matching values - if (Regex.IsMatch(values[i], regexp)) + if (Regex.IsMatch(values[i], valueRegex)) { values.RemoveAt(i); } @@ -168,8 +163,6 @@ public void DeleteMultivarEntry(string name, string regexp) } } - void IDisposable.Dispose() { } - #endregion } } diff --git a/src/shared/TestInfrastructure/Objects/TestGpg.cs b/src/shared/TestInfrastructure/Objects/TestGpg.cs new file mode 100644 index 000000000..34fb01fb0 --- /dev/null +++ b/src/shared/TestInfrastructure/Objects/TestGpg.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Git.CredentialManager.Interop.Posix; + +namespace Microsoft.Git.CredentialManager.Tests.Objects +{ + public class TestGpg : IGpg + { + private readonly TestFileSystem _fs; + private readonly ISet _keys = new HashSet(StringComparer.OrdinalIgnoreCase); + + public TestGpg(TestFileSystem fs) + { + _fs = fs; + } + + public string DecryptFile(string path) + { + // No encryption + return Encoding.UTF8.GetString(_fs.Files[path]); + } + + public void EncryptFile(string path, string gpgId, string contents) + { + if (!_keys.Contains(gpgId)) + { + throw new Exception($"No GPG key found for '{gpgId}'."); + } + + // No encryption + _fs.Files[path] = Encoding.UTF8.GetBytes(contents); + } + + public void GenerateKeys(string userId) + { + _keys.Add(userId); + } + } +} diff --git a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs index 18dc50e6b..b5caab153 100644 --- a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs +++ b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs @@ -12,8 +12,6 @@ public TestHostProvider(ICommandContext context) public Func IsSupportedFunc { get; set; } - public string CredentialKey { get; set; } - public string LegacyAuthorityIdValue { get; set; } public Func GenerateCredentialFunc { get; set; } @@ -28,11 +26,6 @@ public TestHostProvider(ICommandContext context) public override bool IsSupported(InputArguments input) => IsSupportedFunc(input); - public override string GetCredentialKey(InputArguments input) - { - return CredentialKey; - } - public override Task GenerateCredentialAsync(InputArguments input) { return Task.FromResult(GenerateCredentialFunc(input)); diff --git a/src/shared/TestInfrastructure/Objects/TestSettings.cs b/src/shared/TestInfrastructure/Objects/TestSettings.cs index 62b3ec36a..d6e18b021 100644 --- a/src/shared/TestInfrastructure/Objects/TestSettings.cs +++ b/src/shared/TestInfrastructure/Objects/TestSettings.cs @@ -37,6 +37,10 @@ public class TestSettings : ISettings public string ParentWindowId { get; set; } + public string CredentialNamespace { get; set; } = "git-test"; + + public string CredentialBackingStore { get; set; } + #region ISettings public bool TryGetSetting(string envarName, string section, string property, out string value) @@ -115,6 +119,10 @@ Uri ISettings.GetProxyConfiguration(out bool isDeprecatedConfiguration) string ISettings.ParentWindowId => ParentWindowId; + string ISettings.CredentialNamespace => CredentialNamespace; + + string ISettings.CredentialBackingStore => CredentialBackingStore; + #endregion #region IDisposable diff --git a/src/shared/TestInfrastructure/PlatformAttributes.cs b/src/shared/TestInfrastructure/PlatformAttributes.cs index 4acceff7e..b95cd0f8e 100644 --- a/src/shared/TestInfrastructure/PlatformAttributes.cs +++ b/src/shared/TestInfrastructure/PlatformAttributes.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System.Linq; +using System; using System.Runtime.InteropServices; using Xunit; @@ -8,7 +8,7 @@ namespace Microsoft.Git.CredentialManager.Tests { public class PlatformFactAttribute : FactAttribute { - public PlatformFactAttribute(params Platform[] platforms) + public PlatformFactAttribute(Platforms platforms) { if (!XunitHelpers.IsSupportedPlatform(platforms)) { @@ -19,7 +19,7 @@ public PlatformFactAttribute(params Platform[] platforms) public class PlatformTheoryAttribute : TheoryAttribute { - public PlatformTheoryAttribute(params Platform[] platforms) + public PlatformTheoryAttribute(Platforms platforms) { if (!XunitHelpers.IsSupportedPlatform(platforms)) { @@ -30,11 +30,11 @@ public PlatformTheoryAttribute(params Platform[] platforms) internal static class XunitHelpers { - public static bool IsSupportedPlatform(Platform[] platforms) + public static bool IsSupportedPlatform(Platforms platforms) { - if (platforms.Contains(Platform.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || - platforms.Contains(Platform.MacOS) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || - platforms.Contains(Platform.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + if (platforms.HasFlag(Platforms.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || + platforms.HasFlag(Platforms.MacOS) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || + platforms.HasFlag(Platforms.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { return true; } @@ -43,10 +43,14 @@ public static bool IsSupportedPlatform(Platform[] platforms) } } - public enum Platform + [Flags] + public enum Platforms { - Windows, - MacOS, - Linux, + None = 0, + Windows = 1 << 0, + MacOS = 1 << 2, + Linux = 1 << 3, + Posix = MacOS | Linux, + All = Windows | Posix } } diff --git a/src/windows/GitHub.UI.Windows/AuthenticationPrompts.cs b/src/windows/GitHub.UI.Windows/AuthenticationPrompts.cs index 3a5086ac0..cf8f69e74 100644 --- a/src/windows/GitHub.UI.Windows/AuthenticationPrompts.cs +++ b/src/windows/GitHub.UI.Windows/AuthenticationPrompts.cs @@ -16,14 +16,14 @@ public AuthenticationPrompts(IGui gui) private readonly IGui _gui; - public CredentialPromptResult ShowCredentialPrompt(string enterpriseUrl, bool showBasic, bool showOAuth, out string username, out string password) + public CredentialPromptResult ShowCredentialPrompt(string enterpriseUrl, bool showBasic, bool showOAuth, ref string username, out string password) { - username = null; password = null; var viewModel = new LoginCredentialsViewModel(showBasic, showOAuth) { - GitHubEnterpriseUrl = enterpriseUrl + GitHubEnterpriseUrl = enterpriseUrl, + UsernameOrEmail = username }; bool valid = _gui.ShowDialogWindow(viewModel, () => new LoginCredentialsView()); diff --git a/src/windows/GitHub.UI.Windows/Login/LoginCredentialsView.xaml b/src/windows/GitHub.UI.Windows/Login/LoginCredentialsView.xaml index 465be3df0..2d17f5fdc 100644 --- a/src/windows/GitHub.UI.Windows/Login/LoginCredentialsView.xaml +++ b/src/windows/GitHub.UI.Windows/Login/LoginCredentialsView.xaml @@ -81,6 +81,39 @@ + + + + + + + + + + + + + + + + + + + + + + or + + + - - - - - - - - - - - - - - - or - - - - - - - - - diff --git a/src/windows/GitHub.UI.Windows/Program.cs b/src/windows/GitHub.UI.Windows/Program.cs index ff1ef77a8..3bc2b91f7 100644 --- a/src/windows/GitHub.UI.Windows/Program.cs +++ b/src/windows/GitHub.UI.Windows/Program.cs @@ -31,6 +31,7 @@ public static void Main(string[] args) string enterpriseUrl = CommandLineUtils.GetParameter(args, "--enterprise-url"); bool basic = CommandLineUtils.TryGetSwitch(args, "--basic"); bool oauth = CommandLineUtils.TryGetSwitch(args, "--oauth"); + string username = CommandLineUtils.GetParameter(args, "--username"); if (!basic && !oauth) { @@ -39,7 +40,7 @@ public static void Main(string[] args) var result = prompts.ShowCredentialPrompt( enterpriseUrl, basic, oauth, - out string username, + ref username, out string password); switch (result) diff --git a/src/windows/Installer.Windows/Setup.iss b/src/windows/Installer.Windows/Setup.iss index da3a68473..55f6d5b37 100644 --- a/src/windows/Installer.Windows/Setup.iss +++ b/src/windows/Installer.Windows/Setup.iss @@ -83,7 +83,6 @@ Source: "{#PayloadDir}\Atlassian.Bitbucket.UI.exe"; DestDir: Source: "{#PayloadDir}\Atlassian.Bitbucket.UI.exe.config"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\git-credential-manager-core.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\git-credential-manager-core.exe.config"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#PayloadDir}\git2-572e4d8.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\GitHub.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\GitHub.UI.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\GitHub.UI.exe.config"; DestDir: "{app}"; Flags: ignoreversion