diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml new file mode 100644 index 000000000..aaf5e2d43 --- /dev/null +++ b/.azure-pipelines/release.yml @@ -0,0 +1,866 @@ +name: $(Date:yyyyMMdd)$(Rev:.r) +trigger: none +pr: none + +resources: + repositories: + - repository: 1ESPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +parameters: + - name: 'esrp' + type: boolean + default: true + displayName: 'Enable ESRP code signing' + - name: 'github' + type: boolean + default: true + displayName: 'Enable GitHub release publishing' + - name: 'nuget' + type: boolean + default: true + displayName: 'Enable NuGet package publishing' + +# +# 1ES Pipeline Templates do not allow using a matrix strategy so we create +# a YAML object parameter with and foreach to create jobs for each entry. +# Each OS has its own matrix object since their build steps differ. +# + - name: windows_matrix + type: object + default: + - id: windows_x86 + jobName: 'Windows (x86)' + runtime: win-x86 + pool: GitClientPME-1ESHostedPool-intel-pc + image: win-x86_64-ado1es + os: windows + - id: windows_x64 + jobName: 'Windows (x64)' + runtime: win-x64 + pool: GitClientPME-1ESHostedPool-intel-pc + image: win-x86_64-ado1es + os: windows + - id: windows_arm64 + jobName: 'Windows (ARM64)' + runtime: win-arm64 + pool: GitClientPME-1ESHostedPool-arm64-pc + image: win-arm64-ado1es + os: windows + + - name: macos_matrix + type: object + default: + - id: macos_x64 + jobName: 'macOS (x64)' + runtime: osx-x64 + pool: 'Azure Pipelines' + image: macOS-latest + os: macos + - id: macos_arm64 + jobName: 'macOS (ARM64)' + runtime: osx-arm64 + pool: 'Azure Pipelines' + image: macOS-latest + os: macos + + - name: linux_matrix + type: object + default: + - id: linux_x64 + jobName: 'Linux (x64)' + runtime: linux-x64 + pool: GitClientPME-1ESHostedPool-intel-pc + image: ubuntu-x86_64-ado1es + os: linux + - id: linux_arm64 + jobName: 'Linux (ARM64)' + runtime: linux-arm64 + pool: GitClientPME-1ESHostedPool-arm64-pc + image: ubuntu-arm64-ado1es + os: linux + +variables: + - name: 'esrpAppConnectionName' + value: '1ESGitClient-ESRP-App' + - name: 'esrpMIConnectionName' + value: '1ESGitClient-ESRP-MI' + - name: 'githubConnectionName' + value: 'GitHub-GitCredentialManager' + - name: 'nugetConnectionName' + value: '1ESGitClient-NuGet' + # ESRP signing variables set in the pipeline settings: + # - esrpEndpointUrl + # - esrpClientId + # - esrpTenantId + # - esrpKeyVaultName + # - esrpSignReqCertName + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelines + parameters: + sdl: + # SDL source analysis tasks only run on Windows images + sourceAnalysisPool: + name: GitClientPME-1ESHostedPool-intel-pc + image: win-x86_64-ado1es + os: windows + stages: + - stage: build + displayName: 'Build and Sign' + jobs: + # + # Windows build jobs + # + - ${{ each dim in parameters.windows_matrix }}: + - job: ${{ dim.id }} + displayName: ${{ dim.jobName }} + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + templateContext: + outputs: + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)\_final' + artifactName: '${{ dim.runtime }}' + steps: + - checkout: self + - task: PowerShell@2 + displayName: 'Read version file' + inputs: + targetType: inline + script: | + $version = (Get-Content .\VERSION) -replace '\.\d+$', '' + Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version" + - task: UseDotNet@2 + displayName: 'Use .NET 8 SDK' + inputs: + packageType: sdk + version: '8.x' + - task: PowerShell@2 + displayName: 'Build payload' + inputs: + targetType: filePath + filePath: '.\src\windows\Installer.Windows\layout.ps1' + arguments: | + -Configuration Release ` + -Output $(Build.ArtifactStagingDirectory)\payload ` + -SymbolOutput $(Build.ArtifactStagingDirectory)\symbols_raw ` + -RuntimeIdentifier ${{ dim.runtime }} + - task: ArchiveFiles@2 + displayName: 'Archive symbols' + inputs: + rootFolderOrFile: '$(Build.ArtifactStagingDirectory)\symbols_raw' + includeRootFolder: false + archiveType: zip + archiveFile: '$(Build.ArtifactStagingDirectory)\symbols\gcm-${{ dim.runtime }}-$(version)-symbols.zip' + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign payload' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)\payload' + pattern: | + **/*.exe + **/*.dll + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "OpusName": "Microsoft", + "OpusInfo": "https://www.microsoft.com", + "FileDigest": "/fd SHA256", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + }, + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolVerify", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] + - task: PowerShell@2 + displayName: 'Build installers' + inputs: + targetType: inline + script: | + dotnet build '.\src\windows\Installer.Windows\Installer.Windows.csproj' ` + --configuration Release ` + --no-dependencies ` + -p:NoLayout=true ` + -p:PayloadPath="$(Build.ArtifactStagingDirectory)\payload" ` + -p:OutputPath="$(Build.ArtifactStagingDirectory)\installers" ` + -p:RuntimeIdentifier="${{ dim.runtime }}" + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign installers' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)\installers' + pattern: '**/*.exe' + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "OpusName": "Microsoft", + "OpusInfo": "https://www.microsoft.com", + "FileDigest": "/fd SHA256", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + }, + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolVerify", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] + - task: ArchiveFiles@2 + displayName: 'Archive signed payload' + inputs: + rootFolderOrFile: '$(Build.ArtifactStagingDirectory)\payload' + includeRootFolder: false + archiveType: zip + archiveFile: '$(Build.ArtifactStagingDirectory)\installers\gcm-${{ dim.runtime }}-$(version).zip' + - task: PowerShell@2 + displayName: 'Collect artifacts for publishing' + inputs: + targetType: inline + script: | + New-Item -Path "$(Build.ArtifactStagingDirectory)\_final" -ItemType Directory -Force + Copy-Item "$(Build.ArtifactStagingDirectory)\installers\*.exe" -Destination "$(Build.ArtifactStagingDirectory)\_final" + Copy-Item "$(Build.ArtifactStagingDirectory)\installers\*.zip" -Destination "$(Build.ArtifactStagingDirectory)\_final" + Copy-Item "$(Build.ArtifactStagingDirectory)\symbols\*.zip" -Destination "$(Build.ArtifactStagingDirectory)\_final" + Copy-Item "$(Build.ArtifactStagingDirectory)\payload" -Destination "$(Build.ArtifactStagingDirectory)\_final" -Recurse + + # + # macOS build jobs + # + - ${{ each dim in parameters.macos_matrix }}: + - job: ${{ dim.id }} + displayName: ${{ dim.jobName }} + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + templateContext: + outputs: + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/_final' + artifactName: '${{ dim.runtime }}' + steps: + - checkout: self + - task: Bash@3 + displayName: 'Read version file' + inputs: + targetType: inline + script: | + echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + - task: UseDotNet@2 + displayName: 'Use .NET 8 SDK' + inputs: + packageType: sdk + version: '8.x' + - task: Bash@3 + displayName: 'Build payload' + inputs: + targetType: filePath + filePath: './src/osx/Installer.Mac/layout.sh' + arguments: | + --runtime="${{ dim.runtime }}" \ + --configuration="Release" \ + --output="$(Build.ArtifactStagingDirectory)/payload" \ + --symbol-output="$(Build.ArtifactStagingDirectory)/symbols_raw" + - task: ArchiveFiles@2 + displayName: 'Archive symbols' + inputs: + rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/symbols_raw' + includeRootFolder: false + archiveType: tar + tarCompression: gz + archiveFile: '$(Build.ArtifactStagingDirectory)/symbols/gcm-${{ dim.runtime }}-$(version)-symbols.tar.gz' + - task: AzureKeyVault@2 + displayName: 'Download developer certificate' + inputs: + azureSubscription: '$(esrpMIConnectionName)' + keyVaultName: '$(esrpKeyVaultName)' + secretsFilter: 'mac-developer-certificate,mac-developer-certificate-password,mac-developer-certificate-identity' + - task: Bash@3 + displayName: 'Import developer certificate' + inputs: + targetType: inline + script: | + # Create and unlock a keychain for the developer certificate + security create-keychain -p pwd $(Agent.TempDirectory)/buildagent.keychain + security default-keychain -s $(Agent.TempDirectory)/buildagent.keychain + security unlock-keychain -p pwd $(Agent.TempDirectory)/buildagent.keychain + + echo $(mac-developer-certificate) | base64 -D > $(Agent.TempDirectory)/cert.p12 + echo $(mac-developer-certificate-password) > $(Agent.TempDirectory)/cert.password + + # Import the developer certificate + security import $(Agent.TempDirectory)/cert.p12 \ + -k $(Agent.TempDirectory)/buildagent.keychain \ + -P "$(mac-developer-certificate-password)" \ + -T /usr/bin/codesign + + # Clean up the cert file immediately after import + rm $(Agent.TempDirectory)/cert.p12 + + # Set ACLs to allow codesign to access the private key + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k pwd \ + $(Agent.TempDirectory)/buildagent.keychain + - task: Bash@3 + displayName: 'Developer sign payload files' + inputs: + targetType: inline + script: | + mkdir -p $(Build.ArtifactStagingDirectory)/tosign/payload + + # Copy the files that need signing (Mach-o executables and dylibs) + pushd $(Build.ArtifactStagingDirectory)/payload + find . -type f -exec file --mime {} + \ + | sed -n '/mach/s/: .*//p' \ + | while IFS= read -r f; do + rel="${f#./}" + tgt="$(Build.ArtifactStagingDirectory)/tosign/payload/$rel" + mkdir -p "$(dirname "$tgt")" + cp -- "$f" "$tgt" + done + popd + + # Developer sign the files + ./src/osx/Installer.Mac/codesign.sh \ + "$(Build.ArtifactStagingDirectory)/tosign/payload" \ + "$(mac-developer-certificate-identity)" \ + "$PWD/src/osx/Installer.Mac/entitlements.xml" + # ESRP code signing for macOS requires the files be packaged in a zip file for submission + - task: ArchiveFiles@2 + displayName: 'Archive files for signing' + inputs: + rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/tosign/payload' + includeRootFolder: false + archiveType: zip + archiveFile: '$(Build.ArtifactStagingDirectory)/tosign/payload.zip' + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign payload' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)/tosign' + pattern: 'payload.zip' + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-401337-Apple", + "OperationCode": "MacAppDeveloperSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "Hardening": "Enable" + } + } + ] + # Extract signed files, overwriting the unsigned files, ready for packaging + - task: Bash@3 + displayName: 'Extract signed payload files' + inputs: + targetType: inline + script: | + unzip -uo $(Build.ArtifactStagingDirectory)/tosign/payload.zip -d $(Build.ArtifactStagingDirectory)/payload + - task: Bash@3 + displayName: 'Build component package' + inputs: + targetType: filePath + filePath: './src/osx/Installer.Mac/pack.sh' + arguments: | + --version="$(version)" \ + --payload="$(Build.ArtifactStagingDirectory)/payload" \ + --output="$(Build.ArtifactStagingDirectory)/pkg/com.microsoft.gitcredentialmanager.component.pkg" + - task: Bash@3 + displayName: 'Build installer package' + inputs: + targetType: filePath + filePath: './src/osx/Installer.Mac/dist.sh' + arguments: | + --version="$(version)" \ + --runtime="${{ dim.runtime }}" \ + --package-path="$(Build.ArtifactStagingDirectory)/pkg" \ + --output="$(Build.ArtifactStagingDirectory)/installers-presign/gcm-${{ dim.runtime }}-$(version).pkg" + # ESRP code signing for macOS requires the files be packaged in a zip file first + - task: Bash@3 + displayName: 'Prepare installer package for signing' + inputs: + targetType: inline + script: | + mkdir -p $(Build.ArtifactStagingDirectory)/tosign + cd $(Build.ArtifactStagingDirectory)/installers-presign + zip -rX $(Build.ArtifactStagingDirectory)/tosign/installers-presign.zip *.pkg + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign installer package' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)/tosign' + pattern: 'installers-presign.zip' + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-401337-Apple", + "OperationCode": "MacAppDeveloperSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "Hardening": "Enable" + } + } + ] + # Extract signed installer, overwriting the unsigned installer + - task: Bash@3 + displayName: 'Extract signed installer package' + inputs: + targetType: inline + script: | + unzip -uo $(Build.ArtifactStagingDirectory)/tosign/installers-presign.zip -d $(Build.ArtifactStagingDirectory)/installers + - task: Bash@3 + displayName: 'Prepare installer package for notarization' + inputs: + targetType: inline + script: | + mkdir -p $(Build.ArtifactStagingDirectory)/tosign + cd $(Build.ArtifactStagingDirectory)/installers + zip -rX $(Build.ArtifactStagingDirectory)/tosign/installers.zip *.pkg + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Notarize installer package' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)/tosign' + pattern: 'installers.zip' + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-401337-Apple", + "OperationCode": "MacAppNotarize", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "BundleId": "com.microsoft.gitcredentialmanager" + } + } + ] + # Extract signed and notarized installer pkg files, overwriting the unsigned files, ready for upload + - task: Bash@3 + displayName: 'Extract signed and notarized installer package' + inputs: + targetType: inline + script: | + unzip -uo $(Build.ArtifactStagingDirectory)/tosign/installers.zip -d $(Build.ArtifactStagingDirectory)/installers + - task: ArchiveFiles@2 + displayName: 'Archive signed payload' + inputs: + rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/payload' + includeRootFolder: false + archiveType: tar + tarCompression: gz + archiveFile: '$(Build.ArtifactStagingDirectory)/installers/gcm-${{ dim.runtime }}-$(version).tar.gz' + - task: Bash@3 + displayName: 'Collect artifacts for publishing' + inputs: + targetType: inline + script: | + mkdir -p $(Build.ArtifactStagingDirectory)/_final + cp $(Build.ArtifactStagingDirectory)/installers/*.pkg $(Build.ArtifactStagingDirectory)/_final + cp $(Build.ArtifactStagingDirectory)/installers/*.tar.gz $(Build.ArtifactStagingDirectory)/_final + cp $(Build.ArtifactStagingDirectory)/symbols/*.tar.gz $(Build.ArtifactStagingDirectory)/_final + cp -r $(Build.ArtifactStagingDirectory)/payload $(Build.ArtifactStagingDirectory)/_final + + # + # Linux build jobs + # + - ${{ each dim in parameters.linux_matrix }}: + - job: ${{ dim.id }} + displayName: ${{ dim.jobName }} + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + templateContext: + outputs: + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/_final' + artifactName: '${{ dim.runtime }}' + steps: + - checkout: self + - task: Bash@3 + displayName: 'Read version file' + inputs: + targetType: inline + script: | + echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + - task: UseDotNet@2 + displayName: 'Use .NET 8 SDK' + inputs: + packageType: sdk + version: '8.x' + - task: Bash@3 + displayName: 'Build payload' + inputs: + targetType: filePath + filePath: './src/linux/Packaging.Linux/layout.sh' + arguments: | + --runtime="${{ dim.runtime }}" \ + --configuration="Release" \ + --output="$(Build.ArtifactStagingDirectory)/payload" \ + --symbol-output="$(Build.ArtifactStagingDirectory)/symbols_raw" + - task: Bash@3 + displayName: 'Build packages' + inputs: + targetType: filePath + filePath: './src/linux/Packaging.Linux/pack.sh' + arguments: | + --version="$(version)" \ + --runtime="${{ dim.runtime }}" \ + --payload="$(Build.ArtifactStagingDirectory)/payload" \ + --symbols="$(Build.ArtifactStagingDirectory)/symbols_raw" \ + --output="$(Build.ArtifactStagingDirectory)/pkg" + - task: Bash@3 + displayName: 'Move packages' + inputs: + targetType: inline + script: | + # Move symbols + mkdir -p $(Build.ArtifactStagingDirectory)/symbols + mv $(Build.ArtifactStagingDirectory)/pkg/tar/gcm-*-symbols.tar.gz $(Build.ArtifactStagingDirectory)/symbols + + # Move binary packages + mkdir -p $(Build.ArtifactStagingDirectory)/installers + mv $(Build.ArtifactStagingDirectory)/pkg/tar/*.tar.gz $(Build.ArtifactStagingDirectory)/installers + mv $(Build.ArtifactStagingDirectory)/pkg/deb/*.deb $(Build.ArtifactStagingDirectory)/installers + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign Debian package' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)/installers' + pattern: | + **/*.deb + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-453387-Pgp", + "OperationCode": "LinuxSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] + - task: Bash@3 + displayName: 'Collect artifacts for publishing' + inputs: + targetType: inline + script: | + mkdir -p $(Build.ArtifactStagingDirectory)/_final + cp $(Build.ArtifactStagingDirectory)/installers/*.deb $(Build.ArtifactStagingDirectory)/_final + cp $(Build.ArtifactStagingDirectory)/installers/*.tar.gz $(Build.ArtifactStagingDirectory)/_final + cp $(Build.ArtifactStagingDirectory)/symbols/*.tar.gz $(Build.ArtifactStagingDirectory)/_final + cp -r $(Build.ArtifactStagingDirectory)/payload $(Build.ArtifactStagingDirectory)/_final + + # + # .NET Tool build job + # + - job: dotnet_tool + displayName: '.NET Tool NuGet Package' + pool: + name: GitClientPME-1ESHostedPool-intel-pc + image: win-x86_64-ado1es + os: windows + templateContext: + outputs: + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/packages' + artifactName: 'dotnet-tool' + steps: + - checkout: self + - task: PowerShell@2 + displayName: 'Read version file' + inputs: + targetType: inline + script: | + $version = (Get-Content .\VERSION) -replace '\.\d+$', '' + Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version" + - task: UseDotNet@2 + displayName: 'Use .NET 8 SDK' + inputs: + packageType: sdk + version: '8.x' + - task: NuGetToolInstaller@1 + displayName: 'Install NuGet CLI' + inputs: + versionSpec: '>= 6.0' + - task: PowerShell@2 + displayName: 'Build payload' + inputs: + targetType: filePath + filePath: './src/shared/DotnetTool/layout.ps1' + arguments: | + -Configuration Release ` + -Output "$(Build.ArtifactStagingDirectory)/nupkg" + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign payload' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)/nupkg' + pattern: | + **/*.exe + **/*.dll + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "OpusName": "Microsoft", + "OpusInfo": "https://www.microsoft.com", + "FileDigest": "/fd SHA256", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + }, + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolVerify", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] + - task: PowerShell@2 + displayName: 'Create NuGet packages' + inputs: + targetType: filePath + filePath: './src/shared/DotnetTool/pack.ps1' + arguments: | + -Configuration Release ` + -Version "$(version)" ` + -PackageRoot "$(Build.ArtifactStagingDirectory)/nupkg" ` + -Output "$(Build.ArtifactStagingDirectory)/packages" + - task: EsrpCodeSigning@5 + condition: and(succeeded(), eq('${{ parameters.esrp }}', true)) + displayName: 'Sign NuGet packages' + inputs: + connectedServiceName: '$(esrpAppConnectionName)' + useMSIAuthentication: true + appRegistrationClientId: '$(esrpClientId)' + appRegistrationTenantId: '$(esrpTenantId)' + authAkvName: '$(esrpKeyVaultName)' + authSignCertName: '$(esrpSignReqCertName)' + serviceEndpointUrl: '$(esrpEndpointUrl)' + folderPath: '$(Build.ArtifactStagingDirectory)/packages' + pattern: | + **/*.nupkg + **/*.snupkg + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-401405", + "OperationCode": "NuGetSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] + + - stage: release + displayName: 'Release' + dependsOn: [build] + condition: and(succeeded(), or(eq('${{ parameters.github }}', true), eq('${{ parameters.nuget }}', true))) + jobs: + - job: release_validation + displayName: 'Release validation' + pool: + name: GitClientPME-1ESHostedPool-intel-pc + image: ubuntu-x86_64-ado1es + os: linux + steps: + - task: Bash@3 + displayName: 'Read version file' + name: version + inputs: + targetType: inline + script: | + echo "##vso[task.setvariable variable=value;isOutput=true;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + + - job: github + displayName: 'Publish GitHub release' + dependsOn: release_validation + condition: and(succeeded(), eq('${{ parameters.github }}', true)) + pool: + name: GitClientPME-1ESHostedPool-intel-pc + image: ubuntu-x86_64-ado1es + os: linux + variables: + version: $[dependencies.release_validation.outputs['version.value']] + templateContext: + type: releaseJob + isProduction: true + inputs: + # Installers and packages + - input: pipelineArtifact + artifactName: 'win-x86' + targetPath: $(Pipeline.Workspace)/assets/win-x86 + - input: pipelineArtifact + artifactName: 'win-x64' + targetPath: $(Pipeline.Workspace)/assets/win-x64 + - input: pipelineArtifact + artifactName: 'win-arm64' + targetPath: $(Pipeline.Workspace)/assets/win-arm64 + - input: pipelineArtifact + artifactName: 'osx-x64' + targetPath: $(Pipeline.Workspace)/assets/osx-x64 + - input: pipelineArtifact + artifactName: 'osx-arm64' + targetPath: $(Pipeline.Workspace)/assets/osx-arm64 + - input: pipelineArtifact + artifactName: 'linux-x64' + targetPath: $(Pipeline.Workspace)/assets/linux-x64 + - input: pipelineArtifact + artifactName: 'linux-arm64' + targetPath: $(Pipeline.Workspace)/assets/linux-arm64 + - input: pipelineArtifact + artifactName: 'dotnet-tool' + targetPath: $(Pipeline.Workspace)/assets/dotnet-tool + steps: + - task: GitHubRelease@1 + displayName: 'Create Draft GitHub Release' + condition: and(succeeded(), eq('${{ parameters.github }}', true)) + inputs: + gitHubConnection: $(githubConnectionName) + repositoryName: git-ecosystem/git-credential-manager + tag: 'v$(version)' + tagSource: userSpecifiedTag + target: release + title: 'GCM $(version)' + isDraft: true + addChangeLog: false + assets: | + $(Pipeline.Workspace)/assets/win-x86/*.exe + $(Pipeline.Workspace)/assets/win-x86/*.zip + $(Pipeline.Workspace)/assets/win-x64/*.exe + $(Pipeline.Workspace)/assets/win-x64/*.zip + $(Pipeline.Workspace)/assets/win-arm64/*.exe + $(Pipeline.Workspace)/assets/win-arm64/*.zip + $(Pipeline.Workspace)/assets/osx-x64/*.pkg + $(Pipeline.Workspace)/assets/osx-x64/*.tar.gz + $(Pipeline.Workspace)/assets/osx-arm64/*.pkg + $(Pipeline.Workspace)/assets/osx-arm64/*.tar.gz + $(Pipeline.Workspace)/assets/linux-x64/*.deb + $(Pipeline.Workspace)/assets/linux-x64/*.tar.gz + $(Pipeline.Workspace)/assets/linux-arm64/*.deb + $(Pipeline.Workspace)/assets/linux-arm64/*.tar.gz + $(Pipeline.Workspace)/assets/dotnet-tool/*.nupkg + $(Pipeline.Workspace)/assets/dotnet-tool/*.snupkg + + - job: nuget + displayName: 'Publish NuGet package' + dependsOn: release_validation + condition: and(succeeded(), eq('${{ parameters.nuget }}', true)) + pool: + name: GitClientPME-1ESHostedPool-intel-pc + image: ubuntu-x86_64-ado1es + os: linux + variables: + version: $[dependencies.release_validation.outputs['version.value']] + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'dotnet-tool' + targetPath: $(Pipeline.Workspace)/assets/dotnet-tool + outputs: + - output: nuget + condition: and(succeeded(), eq('${{ parameters.nuget }}', true)) + displayName: 'Publish .NET Tool NuGet package' + packagesToPush: '$(Pipeline.Workspace)/assets/dotnet-tool/*.nupkg;$(Pipeline.Workspace)/assets/dotnet-tool/*.snupkg' + packageParentPath: $(Pipeline.Workspace)/assets/dotnet-tool + nuGetFeedType: external + publishPackageMetadata: true + publishFeedCredentials: $(nugetConnectionName) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 745027d8b..b89613684 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,16 +22,16 @@ jobs: language: [ 'csharp' ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4.0.1 + uses: actions/setup-dotnet@v5.0.1 with: dotnet-version: 8.0.x # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} @@ -39,4 +39,4 @@ jobs: dotnet build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 27834c10e..7bb45f26a 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -13,13 +13,22 @@ jobs: # ================================ windows: name: Windows - runs-on: windows-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - runtime: win-x86 + os: windows-latest + - runtime: win-x64 + os: windows-latest + - runtime: win-arm64 + os: windows-11-arm steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4.0.1 + uses: actions/setup-dotnet@v5.0.1 with: dotnet-version: 8.0.x @@ -27,24 +36,29 @@ jobs: run: dotnet restore - name: Build - run: dotnet build --configuration WindowsRelease + run: | + dotnet build src/windows/Installer.Windows/Installer.Windows.csproj ` + --configuration=Release ` + --runtime=${{ matrix.runtime }} - name: Test run: | - dotnet test --verbosity normal --configuration=WindowsRelease + dotnet test --verbosity normal ` + --configuration=WindowsRelease ` + --runtime=${{ matrix.runtime }} - name: Prepare artifacts shell: bash run: | mkdir -p artifacts/bin - mv out/windows/Installer.Windows/bin/Release/net472/win-x86 artifacts/bin/ - cp out/windows/Installer.Windows/bin/Release/net472/win-x86.sym/* artifacts/bin/win-x86/ - mv out/windows/Installer.Windows/bin/Release/net472/gcm*.exe artifacts/ + mv out/windows/Installer.Windows/bin/Release/net472/${{ matrix.runtime }}/gcm*.exe artifacts/ + mv out/windows/Installer.Windows/bin/Release/net472/${{ matrix.runtime }} artifacts/bin/ + cp out/windows/Installer.Windows/bin/Release/net472/${{ matrix.runtime }}.sym/* artifacts/bin/${{ matrix.runtime }}/ - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: - name: win-x86 + name: ${{ matrix.runtime }} path: | artifacts @@ -54,12 +68,15 @@ jobs: linux: name: Linux runs-on: ubuntu-latest + strategy: + matrix: + runtime: [ linux-x64, linux-arm64, linux-arm ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4.0.1 + uses: actions/setup-dotnet@v5.0.1 with: dotnet-version: 8.0.x @@ -67,7 +84,10 @@ jobs: run: dotnet restore - name: Build - run: dotnet build --configuration LinuxRelease + run: | + dotnet build src/linux/Packaging.Linux/*.csproj \ + --configuration=Release --no-self-contained \ + --runtime=${{ matrix.runtime }} - name: Test run: | @@ -80,9 +100,9 @@ jobs: mv out/linux/Packaging.Linux/Release/tar/*.tar.gz artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: - name: linux-x64 + name: ${{ matrix.runtime }} path: | artifacts @@ -97,10 +117,10 @@ jobs: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4.0.1 + uses: actions/setup-dotnet@v5.0.1 with: dotnet-version: 8.0.x @@ -125,7 +145,7 @@ jobs: mv out/osx/Installer.Mac/pkg/Release/gcm*.pkg artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ matrix.runtime }} path: | diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 6f153d5f6..9fecfd863 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -18,9 +18,9 @@ jobs: name: Lint markdown files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: DavidAnson/markdownlint-cli2-action@b4c9feab76d8025d1e83c653fa3990936df0e6c8 + - uses: DavidAnson/markdownlint-cli2-action@07035fd053f7be764496c0f8d8f9f41f98305101 with: globs: | "**/*.md" @@ -30,13 +30,12 @@ jobs: name: Check for broken links runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Run link checker # For any troubleshooting, see: # https://github.com/lycheeverse/lychee/blob/master/docs/TROUBLESHOOTING.md - uses: lycheeverse/lychee-action@2b973e86fc7b1f6b36a93795fe2c9c6ae1118621 - + uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 with: # user-agent: if a user agent is not specified, some websites (e.g. # GitHub Docs) return HTTP errors which Lychee will interpret as diff --git a/.github/workflows/maintainer-absence.yml b/.github/workflows/maintainer-absence.yml index 433cb0f7e..20e6694e7 100644 --- a/.github/workflows/maintainer-absence.yml +++ b/.github/workflows/maintainer-absence.yml @@ -18,7 +18,7 @@ jobs: name: create-issue runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v8 with: script: | const startDate = new Date('${{ github.event.inputs.startDate }}'); diff --git a/.github/workflows/release-dotnet-tool.yaml b/.github/workflows/release-dotnet-tool.yaml deleted file mode 100644 index 594a2f4a3..000000000 --- a/.github/workflows/release-dotnet-tool.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: release-dotnet-tool -on: - release: - types: [released] - -jobs: - release: - runs-on: windows-latest - environment: release - steps: - - name: Download NuGet package from release and publish - run: | - # Get asset information - $github = Get-Content '${{ github.event_path }}' | ConvertFrom-Json - $asset = $github.release.assets | Where-Object -Property name -match '.nupkg$' - - # Download asset - Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $asset.name - - # Publish asset - dotnet nuget push $asset.name --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json - shell: powershell diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 9f578e109..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,662 +0,0 @@ -name: release - -on: - workflow_dispatch: - -permissions: - id-token: write - contents: write - -jobs: - prereqs: - name: Prerequisites - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "version=$(cat VERSION | sed -E 's/.[0-9]+$//')" >> $GITHUB_OUTPUT - id: version - -# ================================ -# macOS -# ================================ - create-macos-artifacts: - name: Create macOS artifacts - runs-on: macos-latest - environment: release - needs: prereqs - strategy: - matrix: - runtime: [ osx-x64, osx-arm64 ] - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Build - run: | - dotnet build src/osx/Installer.Mac/*.csproj \ - --configuration=MacRelease --no-self-contained \ - --runtime=${{ matrix.runtime }} - - - name: Run macOS unit tests - run: | - dotnet test --configuration=MacRelease - - - name: Lay out payload and symbols - run: | - src/osx/Installer.Mac/layout.sh \ - --configuration=MacRelease --output=payload \ - --symbol-output=symbols --runtime=${{ matrix.runtime }} - - - name: Set up signing/notarization infrastructure - env: - A1: ${{ secrets.APPLICATION_CERTIFICATE_BASE64 }} - A2: ${{ secrets.APPLICATION_CERTIFICATE_PASSWORD }} - I1: ${{ secrets.INSTALLER_CERTIFICATE_BASE64 }} - I2: ${{ secrets.INSTALLER_CERTIFICATE_PASSWORD }} - N1: ${{ secrets.APPLE_TEAM_ID }} - N2: ${{ secrets.APPLE_DEVELOPER_ID }} - N3: ${{ secrets.APPLE_DEVELOPER_PASSWORD }} - N4: ${{ secrets.APPLE_KEYCHAIN_PROFILE }} - run: | - echo "Setting up signing certificates" - security create-keychain -p pwd $RUNNER_TEMP/buildagent.keychain - security default-keychain -s $RUNNER_TEMP/buildagent.keychain - security unlock-keychain -p pwd $RUNNER_TEMP/buildagent.keychain - - echo $A1 | base64 -D > $RUNNER_TEMP/cert.p12 - security import $RUNNER_TEMP/cert.p12 \ - -k $RUNNER_TEMP/buildagent.keychain \ - -P $A2 \ - -T /usr/bin/codesign - security set-key-partition-list \ - -S apple-tool:,apple:,codesign: \ - -s -k pwd \ - $RUNNER_TEMP/buildagent.keychain - - echo $I1 | base64 -D > $RUNNER_TEMP/cert.p12 - security import $RUNNER_TEMP/cert.p12 \ - -k $RUNNER_TEMP/buildagent.keychain \ - -P $I2 \ - -T /usr/bin/productbuild - security set-key-partition-list \ - -S apple-tool:,apple:,productbuild: \ - -s -k pwd \ - $RUNNER_TEMP/buildagent.keychain - - echo "Setting up notarytool" - xcrun notarytool store-credentials \ - --team-id $N1 \ - --apple-id $N2 \ - --password $N3 \ - "$N4" - - - name: Run codesign against payload - env: - A3: ${{ secrets.APPLE_APPLICATION_SIGNING_IDENTITY }} - run: | - ./src/osx/Installer.Mac/codesign.sh "payload" "$A3" \ - "$GITHUB_WORKSPACE/src/osx/Installer.Mac/entitlements.xml" - - - name: Create component package - run: | - src/osx/Installer.Mac/pack.sh --payload="payload" \ - --version="${{ needs.prereqs.outputs.version }}" \ - --output="components/com.microsoft.gitcredentialmanager.component.pkg" - - - name: Create and sign product archive - env: - I3: ${{ secrets.APPLE_INSTALLER_SIGNING_IDENTITY }} - run: | - src/osx/Installer.Mac/dist.sh --package-path=components \ - --version="${{ needs.prereqs.outputs.version }}" \ - --runtime="${{ matrix.runtime }}" \ - --output="pkg/gcm-${{ matrix.runtime }}-${{ needs.prereqs.outputs.version }}.pkg" \ - --identity="$I3" || exit 1 - - - name: Notarize product archive - env: - N4: ${{ secrets.APPLE_KEYCHAIN_PROFILE }} - run: | - src/osx/Installer.Mac/notarize.sh \ - --package="pkg/gcm-${{ matrix.runtime }}-${{ needs.prereqs.outputs.version }}.pkg" \ - --keychain-profile="$N4" - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: macos-${{ matrix.runtime }}-artifacts - path: | - ./pkg/* - ./symbols/* - ./payload/* - -# ================================ -# Windows -# ================================ - create-windows-artifacts: - name: Create Windows Artifacts - runs-on: windows-latest - environment: release - needs: prereqs - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Build - run: | - dotnet build --configuration=WindowsRelease - - - name: Run Windows unit tests - run: | - dotnet test --configuration=WindowsRelease - - - name: Lay out Windows payload and symbols - run: | - cd $env:GITHUB_WORKSPACE\src\windows\Installer.Windows\ - ./layout.ps1 -Configuration WindowsRelease ` - -Output $env:GITHUB_WORKSPACE\payload ` - -SymbolOutput $env:GITHUB_WORKSPACE\symbols - - - name: Log into Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Sign payload files with Azure Code Signing - uses: azure/trusted-signing-action@v0.4.0 - with: - endpoint: https://wus2.codesigning.azure.net/ - trusted-signing-account-name: git-fundamentals-signing - certificate-profile-name: git-fundamentals-windows-signing - files-folder: ${{ github.workspace }}\payload - files-folder-filter: exe,dll - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - # The Azure Code Signing action overrides the .NET version, so we reset it. - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Build with signed payload - run: | - dotnet build $env:GITHUB_WORKSPACE\src\windows\Installer.Windows ` - /p:PayloadPath=$env:GITHUB_WORKSPACE\payload /p:NoLayout=true ` - --configuration=WindowsRelease - mkdir installers - Move-Item -Path .\out\windows\Installer.Windows\bin\Release\net472\*.exe ` - -Destination $env:GITHUB_WORKSPACE\installers - - - name: Sign installers with Azure Code Signing - uses: azure/trusted-signing-action@v0.4.0 - with: - endpoint: https://wus2.codesigning.azure.net/ - trusted-signing-account-name: git-fundamentals-signing - certificate-profile-name: git-fundamentals-windows-signing - files-folder: ${{ github.workspace }}\installers - files-folder-filter: exe - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: windows-artifacts - path: | - payload - installers - symbols - -# ================================ -# Linux -# ================================ - create-linux-artifacts: - name: Create Linux Artifacts - runs-on: ubuntu-latest - environment: release - needs: prereqs - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Build - run: dotnet build --configuration=LinuxRelease - - - name: Run Linux unit tests - run: | - dotnet test --configuration=LinuxRelease - - - name: Log into Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Prepare for GPG signing - env: - AZURE_VAULT: ${{ secrets.AZURE_VAULT }} - GPG_KEY_SECRET_NAME: ${{ secrets.GPG_KEY_SECRET_NAME }} - GPG_PASSPHRASE_SECRET_NAME: ${{ secrets.GPG_PASSPHRASE_SECRET_NAME }} - GPG_KEYGRIP_SECRET_NAME: ${{ secrets.GPG_KEYGRIP_SECRET_NAME }} - run: | - # Install debsigs - sudo apt install debsigs - - # Download GPG key, passphrase, and keygrip from Azure Key Vault - key=$(az keyvault secret show --name $GPG_KEY_SECRET_NAME --vault-name $AZURE_VAULT --query "value") - passphrase=$(az keyvault secret show --name $GPG_PASSPHRASE_SECRET_NAME --vault-name $AZURE_VAULT --query "value") - keygrip=$(az keyvault secret show --name $GPG_KEYGRIP_SECRET_NAME --vault-name $AZURE_VAULT --query "value") - - # Remove quotes from downloaded values - key=$(sed -e 's/^"//' -e 's/"$//' <<<"$key") - passphrase=$(sed -e 's/^"//' -e 's/"$//' <<<"$passphrase") - keygrip=$(sed -e 's/^"//' -e 's/"$//' <<<"$keygrip") - - # Import GPG key - echo "$key" | base64 -d | gpg --import --no-tty --batch --yes - - # Configure GPG - echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf - gpg-connect-agent RELOADAGENT /bye - /usr/lib/gnupg2/gpg-preset-passphrase --preset "$keygrip" <<<"$passphrase" - - - name: Sign Debian package and tarball - run: | - # Sign Debian package - version=${{ needs.prereqs.outputs.version }} - mv out/linux/Packaging.Linux/Release/deb/gcm-linux_amd64.$version.deb . - debsigs --sign=origin --verify --check gcm-linux_amd64.$version.deb - - # Generate tarball signature file - mv -v out/linux/Packaging.Linux/Release/tar/* . - gpg --batch --yes --armor --output gcm-linux_amd64.$version.tar.gz.asc \ - --detach-sig gcm-linux_amd64.$version.tar.gz - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: linux-artifacts - path: | - ./*.deb - ./*.asc - ./*.tar.gz - -# ================================ -# .NET Tool -# ================================ - dotnet-tool-build: - name: Build .NET tool - runs-on: ubuntu-latest - needs: prereqs - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Build .NET tool - run: | - src/shared/DotnetTool/layout.sh --configuration=Release - - - name: Upload .NET tool artifacts - uses: actions/upload-artifact@v4 - with: - name: tmp.dotnet-tool-build - path: | - out/shared/DotnetTool/nupkg/Release - - dotnet-tool-payload-sign: - name: Sign .NET tool payload - runs-on: windows-latest - environment: release - needs: dotnet-tool-build - steps: - - uses: actions/checkout@v4 - - - name: Download payload - uses: actions/download-artifact@v4 - with: - name: tmp.dotnet-tool-build - - - name: Log into Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Install sign CLI tool - run: | - dotnet tool install -g sign --version 0.9.1-beta.24325.5 - - - name: Sign payload - run: | - sign.exe code trusted-signing payload/* ` - -tse https://wus2.codesigning.azure.net/ ` - -tsa git-fundamentals-signing ` - -tscp git-fundamentals-windows-signing - - - name: Lay out signed payload, images, and symbols - shell: bash - run: | - mkdir dotnet-tool-payload-sign - mv images payload.sym payload -t dotnet-tool-payload-sign - - - name: Upload signed payload - uses: actions/upload-artifact@v4 - with: - name: dotnet-tool-payload-sign - path: | - dotnet-tool-payload-sign - - dotnet-tool-pack: - name: Package .NET tool - runs-on: ubuntu-latest - needs: [ prereqs, dotnet-tool-payload-sign ] - steps: - - uses: actions/checkout@v4 - - - name: Download signed payload - uses: actions/download-artifact@v4 - with: - name: dotnet-tool-payload-sign - path: signed - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Package tool - run: | - src/shared/DotnetTool/pack.sh --configuration=Release \ - --version="${{ needs.prereqs.outputs.version }}" \ - --publish-dir=$(pwd)/signed - - - name: Upload unsigned package - uses: actions/upload-artifact@v4 - with: - name: tmp.dotnet-tool-package-unsigned - path: | - out/shared/DotnetTool/nupkg/Release/*.nupkg - - dotnet-tool-sign: - name: Sign .NET tool package - runs-on: windows-latest - environment: release - needs: dotnet-tool-pack - steps: - - uses: actions/checkout@v4 - - - name: Download unsigned package - uses: actions/download-artifact@v4 - with: - name: tmp.dotnet-tool-package-unsigned - path: nupkg - - - name: Log into Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Install sign CLI tool - run: | - dotnet tool install -g sign --version 0.9.1-beta.24325.5 - - - name: Sign package - run: | - sign.exe code trusted-signing nupkg/* ` - -tse https://wus2.codesigning.azure.net/ ` - -tsa git-fundamentals-signing ` - -tscp git-fundamentals-windows-signing - - mv nupkg/* . - - # Remove this once NuGet supports the subscriber identity validation EKU: - # https://github.com/NuGet/NuGetGallery/issues/10027 - - name: Extract signing certificate from package - shell: pwsh - run: | - dotnet tool install --global Knapcode.CertificateExtractor - $nupkg = gci *.nupkg - nuget-cert-extractor --file $nupkg --output certs --code-signing --author --leaf - $cert = gci certs\*.cer - mv $cert .\nuget-signing.cer - - - name: Publish signed package and certificate - uses: actions/upload-artifact@v4 - with: - name: dotnet-tool-sign - path: | - *.nupkg - *.cer - -# ================================ -# Validate -# ================================ - validate: - name: Validate installers - strategy: - matrix: - component: - - os: ubuntu-latest - artifact: linux-artifacts - command: git-credential-manager - description: linux - - os: macos-latest - artifact: macos-osx-x64-artifacts - command: git-credential-manager - description: osx-x64 - - os: windows-latest - artifact: windows-artifacts - # Even when a standalone GCM version is installed, GitHub actions - # runners still only recognize the version bundled with Git for - # Windows due to its placement on the PATH. For this reason, we use - # the full path to our installation to validate the Windows version. - command: "$PROGRAMFILES (x86)/Git Credential Manager/git-credential-manager.exe" - description: windows - - os: ubuntu-latest - artifact: dotnet-tool-sign - command: git-credential-manager - description: dotnet-tool - runs-on: ${{ matrix.component.os }} - needs: [ create-macos-artifacts, create-windows-artifacts, create-linux-artifacts, dotnet-tool-sign ] - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.component.artifact }} - - - name: Install Windows - if: contains(matrix.component.description, 'windows') - shell: pwsh - run: | - $exePaths = Get-ChildItem -Path ./installers/*.exe | %{$_.FullName} - foreach ($exePath in $exePaths) - { - Start-Process -Wait -FilePath "$exePath" -ArgumentList "/SILENT /VERYSILENT /NORESTART" - } - - - name: Install Linux (Debian package) - if: contains(matrix.component.description, 'linux') - run: | - debpath=$(find ./*.deb) - sudo apt install $debpath - "${{ matrix.component.command }}" configure - - - name: Install Linux (tarball) - if: contains(matrix.component.description, 'linux') - run: | - # Ensure we find only the source tarball, not the symbols - tarpath=$(find . -name '*[[:digit:]].tar.gz') - tar -xvf $tarpath -C /usr/local/bin - "${{ matrix.component.command }}" configure - - - name: Install macOS - if: contains(matrix.component.description, 'osx-x64') - run: | - # Only validate x64, given arm64 agents are not available - pkgpath=$(find ./pkg/*.pkg) - sudo installer -pkg $pkgpath -target / - - - name: Install .NET tool - if: contains(matrix.component.description, 'dotnet-tool') - run: | - nupkgpath=$(find ./*.nupkg) - dotnet tool install -g --add-source $(dirname "$nupkgpath") git-credential-manager - "${{ matrix.component.command }}" configure - - - name: Validate - shell: bash - run: | - "${{ matrix.component.command }}" --version | sed 's/+.*//' >actual - cat VERSION | sed -E 's/.[0-9]+$//' >expect - cmp expect actual || exit 1 - -# ================================ -# Publish -# ================================ - create-github-release: - name: Publish GitHub draft release - runs-on: ubuntu-latest - env: - AZURE_VAULT: ${{ secrets.AZURE_VAULT }} - GPG_PUBLIC_KEY_SECRET_NAME: ${{ secrets.GPG_PUBLIC_KEY_SECRET_NAME }} - environment: release - needs: [ prereqs, validate ] - steps: - - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4.0.1 - with: - dotnet-version: 8.0.x - - - name: Download artifacts - uses: actions/download-artifact@v4 - - - name: Archive macOS payload and symbols - run: | - version="${{ needs.prereqs.outputs.version }}" - mkdir osx-payload-and-symbols - - tar -C macos-osx-x64-artifacts/payload -czf osx-payload-and-symbols/gcm-osx-x64-$version.tar.gz . - tar -C macos-osx-x64-artifacts/symbols -czf osx-payload-and-symbols/gcm-osx-x64-$version-symbols.tar.gz . - - tar -C macos-osx-arm64-artifacts/payload -czf osx-payload-and-symbols/gcm-osx-arm64-$version.tar.gz . - tar -C macos-osx-arm64-artifacts/symbols -czf osx-payload-and-symbols/gcm-osx-arm64-$version-symbols.tar.gz . - - - name: Archive Windows payload and symbols - run: | - version="${{ needs.prereqs.outputs.version }}" - mkdir win-x86-payload-and-symbols - zip -jr win-x86-payload-and-symbols/gcm-win-x86-$version.zip windows-artifacts/payload - zip -jr win-x86-payload-and-symbols/gcm-win-x86-$version-symbols.zip windows-artifacts/symbols - - - name: Log into Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Download GPG public key signature file - run: | - az keyvault secret show --name "$GPG_PUBLIC_KEY_SECRET_NAME" \ - --vault-name "$AZURE_VAULT" --query "value" \ - | sed -e 's/^"//' -e 's/"$//' | base64 -d >gcm-public.asc - mv gcm-public.asc linux-artifacts - - - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const path = require('path'); - const version = "${{ needs.prereqs.outputs.version }}" - - var releaseMetadata = { - owner: context.repo.owner, - repo: context.repo.repo - }; - - // Create the release - var tagName = `v${version}`; - var createdRelease = await github.rest.repos.createRelease({ - ...releaseMetadata, - draft: true, - tag_name: tagName, - target_commitish: context.sha, - name: `GCM ${version}` - }); - releaseMetadata.release_id = createdRelease.data.id; - - // Uploads contents of directory to the release created above - async function uploadDirectoryToRelease(directory, includeExtensions=[]) { - return fs.promises.readdir(directory) - .then(async(files) => Promise.all( - files.filter(file => { - return includeExtensions.length==0 || includeExtensions.includes(path.extname(file).toLowerCase()); - }) - .map(async (file) => { - var filePath = path.join(directory, file); - github.rest.repos.uploadReleaseAsset({ - ...releaseMetadata, - name: file, - headers: { - "content-length": (await fs.promises.stat(filePath)).size - }, - data: fs.createReadStream(filePath) - }); - })) - ); - } - - await Promise.all([ - // Upload Windows artifacts - uploadDirectoryToRelease('windows-artifacts/installers'), - uploadDirectoryToRelease('win-x86-payload-and-symbols'), - - // Upload macOS artifacts - uploadDirectoryToRelease('macos-osx-x64-artifacts/pkg'), - uploadDirectoryToRelease('macos-osx-arm64-artifacts/pkg'), - uploadDirectoryToRelease('osx-payload-and-symbols'), - - // Upload Linux artifacts - uploadDirectoryToRelease('linux-artifacts'), - - // Upload .NET tool package - uploadDirectoryToRelease('dotnet-tool-sign'), - ]); diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index ca57d2daf..d1aea471a 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -21,13 +21,14 @@ jobs: # tgagor is a contributor who pushes updated images weekly, which should # be sufficient for our validation needs. - image: tgagor/centos - - image: tgagor/centos-stream - image: redhat/ubi8 - image: alpine - - image: alpine:3.14.10 + - image: alpine:3.19.8 - image: opensuse/leap - image: opensuse/tumbleweed - image: registry.suse.com/suse/sle15:15.4.27.11.31 + - image: archlinux + - image: mcr.microsoft.com/cbl-mariner/base/core:2.0 container: ${{matrix.vector.image}} steps: - run: | @@ -35,9 +36,12 @@ jobs: zypper -n install tar gzip elif [[ ${{matrix.vector.image}} == *"centos"* ]]; then dnf install which -y + elif [[ ${{matrix.vector.image}} == *"mariner"* ]]; then + GNUPGHOME=/root/.gnupg tdnf update -y && + GNUPGHOME=/root/.gnupg tdnf install tar -y # needed for `actions/checkout` fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - run: | sh "${GITHUB_WORKSPACE}/src/linux/Packaging.Linux/install-from-source.sh" -y diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..87c4b2935 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +* @git-ecosystem/git-client +/src/shared/Microsoft.AzureRepos/ @git-ecosystem/git-client @git-ecosystem/gcm-azure-maintainers +/src/shared/Microsoft.AzureRepos.Tests/ @git-ecosystem/git-client @git-ecosystem/gcm-azure-maintainers +/src/shared/GitHub/ @git-ecosystem/git-client @git-ecosystem/hubbers +/src/shared/GitHub.Tests/ @git-ecosystem/git-client @git-ecosystem/hubbers diff --git a/Directory.Build.props b/Directory.Build.props index 5c0d87bdb..e7ed76eb9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -28,7 +28,7 @@ - 8.0.4 + 8.0.5 diff --git a/README.md b/README.md index 18c9b1309..b9dcff451 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ Basic HTTP authentication support|✓|✓|✓ Proxy support|✓|✓|✓ `amd64` support|✓|✓|✓ `x86` support|✓|_N/A_|✗ -`arm64` support|best effort|✓|best effort, no packages -`armhf` support|_N/A_|_N/A_|best effort, no packages +`arm64` support|best effort|✓|✓ +`armhf` support|_N/A_|_N/A_|✓ (\*) GCM guarantees support only for [the Linux distributions that are officially supported by dotnet][dotnet-distributions]. @@ -56,7 +56,7 @@ supported by dotnet][dotnet-distributions]. ## Supported Git versions Git Credential Manager tries to be compatible with the broadest set of Git -versions (within reason). However there are some know problematic releases of +versions (within reason). However there are some known problematic releases of Git that are not compatible. - Git 1.x diff --git a/VERSION b/VERSION index 82f00d533..21b5059f4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.0.0 +2.7.0.0 diff --git a/build/GetVersion.cs b/build/GetVersion.cs index 2b3473641..0078c4c12 100644 --- a/build/GetVersion.cs +++ b/build/GetVersion.cs @@ -34,7 +34,7 @@ public override bool Execute() // The main version number we use for GCM contains the first three // components. // The assembly and file version numbers contain all components, as - // ommitting the revision portion from these properties causes + // omitting the revision portion from these properties causes // runtime failures on Windows. Version = $"{fullVersion.Major}.{fullVersion.Minor}.{fullVersion.Build}"; AssemblyVersion = FileVersion = fullVersion.ToString(); diff --git a/docs/configuration.md b/docs/configuration.md index a4fecf395..ba978ef30 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -255,6 +255,24 @@ Defaults to false (use hardware acceleration where available). --- +### credential.allowUnsafeRemotes + +Allow transmitting credentials to unsafe remote URLs such as unencrypted HTTP +URLs. This setting is not recommended for general use and should only be used +when necessary. + +Defaults false (disallow unsafe remote URLs). + +#### Example + +```shell +git config --global credential.allowUnsafeRemotes true +``` + +**Also see: [GCM_ALLOW_UNSAFE_REMOTES][gcm-allow-unsafe-remotes]** + +--- + ### credential.autoDetectTimeout Set the maximum length of time, in milliseconds, that GCM should wait for a @@ -567,6 +585,7 @@ _(unset)_|Windows: `wincredman`, macOS: `keychain`, Linux: _(none)_|- `gpg`|Use GPG to store encrypted files that are compatible with the [pass][pass] (requires GPG and `pass` to initialize the store).|macOS, Linux `cache`|Git's built-in [credential cache][credential-cache].|macOS, Linux `plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`credential.plaintextStorePath`][credential-plaintextstorepath].|Windows, macOS, Linux +`none`|Do not store credentials via GCM.|Windows, macOS, Linux #### Example @@ -1022,8 +1041,9 @@ Defaults to disabled. [devbox]: https://azure.microsoft.com/en-us/products/dev-box [enterprise-config]: enterprise-config.md [envars]: environment.md -[freedesktop-ss]: https://specifications.freedesktop.org/secret-service/ +[freedesktop-ss]: https://specifications.freedesktop.org/secret-service-spec/ [gcm-allow-windowsauth]: environment.md#GCM_ALLOW_WINDOWSAUTH +[gcm-allow-unsafe-remotes]: environment.md#GCM_ALLOW_UNSAFE_REMOTES [gcm-authority]: environment.md#GCM_AUTHORITY-deprecated [gcm-autodetect-timeout]: environment.md#GCM_AUTODETECT_TIMEOUT [gcm-azrepos-credentialtype]: environment.md#GCM_AZREPOS_CREDENTIALTYPE diff --git a/docs/credstores.md b/docs/credstores.md index 9154cd441..ca76f5692 100644 --- a/docs/credstores.md +++ b/docs/credstores.md @@ -9,6 +9,7 @@ There are several options for storing credentials that GCM supports: - GPG/[`pass`][passwordstore] compatible files - Git's built-in [credential cache][credential-cache] - Plaintext files +- Passthrough/no-op (no credential store) The default credential stores on macOS and Windows are the macOS Keychain and the Windows Credential Manager, respectively. @@ -251,13 +252,38 @@ permissions on this directory such that no other users or applications can access files within. If possible, use a path that exists on an external volume that you take with you and use full-disk encryption. +## Passthrough/no-op (no credential store) + +**Available on:** _Windows, macOS, Linux_ + +**:warning: .** + +```batch +SET GCM_CREDENTIAL_STORE="none" +``` + +or + +```shell +git config --global credential.credentialStore none +``` + +This option disables the internal credential store. All operations to store or +retrieve credentials will do nothing, and will return success. This is useful if +you want to use a different credential store, chained in sequence via Git +configuration, and don't want GCM to store credentials. + +Note that you'll want to ensure that another credential helper is placed before +GCM in the `credential.helper` Git configuration or else you will be prompted to +enter your credentials every time you interact with a remote repository. + [access-windows-credential-manager]: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 [aws-cloudshell]: https://aws.amazon.com/cloudshell/ [azure-cloudshell]: https://docs.microsoft.com/azure/cloud-shell/overview [cmdkey]: https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/cmdkey [credential-store]: configuration.md#credentialcredentialstore [credential-cache]: https://git-scm.com/docs/git-credential-cache -[freedesktop-secret-service]: https://specifications.freedesktop.org/secret-service-spec/latest/ +[freedesktop-secret-service]: https://specifications.freedesktop.org/secret-service/ [gcm-credential-store]: environment.md#GCM_CREDENTIAL_STORE [git-credential-store]: https://git-scm.com/docs/git-credential-store [mac-keychain-management]: https://support.apple.com/en-gb/guide/mac-help/mchlf375f392/mac diff --git a/docs/development.md b/docs/development.md index 7729556f9..0242d68b8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -54,6 +54,12 @@ To build from the command line, run: dotnet build -c LinuxDebug ``` +If you want to build for a specific architecture, you can provide `linux-x64` or `linux-arm64` or `linux-arm` as the runtime: + +```shell +dotnet build -c LinuxDebug -r linux-arm64 +``` + You can find a copy of the Debian package (.deb) file in `out/linux/Packaging.Linux/deb/Debug`. The flat binaries can also be found in `out/linux/Packaging.Linux/payload/Debug`. diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md index bfdc7e302..97544a33f 100644 --- a/docs/enterprise-config.md +++ b/docs/enterprise-config.md @@ -55,7 +55,38 @@ those of the [Git configuration][config] settings. The type of each registry key can be either `REG_SZ` (string) or `REG_DWORD` (integer). -## macOS/Linux +## macOS + +Default settings values come from macOS's preferences system. Configuration +profiles can be deployed to devices using a compatible Mobile Device Management +(MDM) solution. + +Configuration for Git Credential Manager must take the form of a dictionary, set +for the domain `git-credential-manager` under the key `configuration`. For +example: + +```shell +defaults write git-credential-manager configuration -dict-add +``` + +..where `` is the name of the settings from the [Git configuration][config] +reference, and `` is the desired value. + +All values in the `configuration` dictionary must be strings. For boolean values +use `true` or `false`, and for integer values use the number in string form. + +To read the current configuration: + +```console +$ defaults read git-credential-manager configuration +{ + = ; + ... + = ; +} +``` + +## Linux Default configuration setting stores has not been implemented. diff --git a/docs/environment.md b/docs/environment.md index edda0d714..f321caa6c 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -302,6 +302,32 @@ Defaults to false (use hardware acceleration where available). --- +### GCM_ALLOW_UNSAFE_REMOTES + +Allow transmitting credentials to unsafe remote URLs such as unencrypted HTTP +URLs. This setting is not recommended for general use and should only be used +when necessary. + +Defaults false (disallow unsafe remote URLs). + +#### Example + +##### Windows + +```batch +SET GCM_ALLOW_UNSAFE_REMOTES=true +``` + +##### macOS/Linux + +```bash +export GCM_ALLOW_UNSAFE_REMOTES=true +``` + +**Also see: [credential.allowUnsafeRemotes][credential-allowunsaferemotes]** + +--- + ### GCM_AUTODETECT_TIMEOUT Set the maximum length of time, in milliseconds, that GCM should wait for a @@ -690,6 +716,7 @@ _(unset)_|Windows: `wincredman`, macOS: `keychain`, Linux: _(none)_|- `gpg`|Use GPG to store encrypted files that are compatible with the [`pass` utility][passwordstore] (requires GPG and `pass` to initialize the store).|macOS, Linux `cache`|Git's built-in [credential cache][git-credential-cache].|Windows, macOS, Linux `plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`GCM_PLAINTEXT_STORE_PATH`][gcm-plaintext-store-path].|Windows, macOS, Linux +`none`|Do not store credentials via GCM.|Windows, macOS, Linux #### Windows @@ -1153,7 +1180,8 @@ Defaults to disabled. [autodetect]: autodetect.md [azure-access-tokens]: azrepos-users-and-tokens.md [configuration]: configuration.md -[credential-allowwindowsauth]: environment.md#credentialallowWindowsAuth +[credential-allowwindowsauth]: configuration.md#credentialallowwindowsauth +[credential-allowunsaferemotes]: configuration.md#credentialallowunsaferemotes [credential-authority]: configuration.md#credentialauthority-deprecated [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout [credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype @@ -1182,7 +1210,7 @@ Defaults to disabled. [credential-trace-msauth]: configuration.md#credentialtracemsauth [default-values]: enterprise-config.md [devbox]: https://azure.microsoft.com/en-us/products/dev-box -[freedesktop-ss]: https://specifications.freedesktop.org/secret-service/ +[freedesktop-ss]: https://specifications.freedesktop.org/secret-service-spec/ [gcm]: usage.md [gcm-interactive]: #gcm_interactive [gcm-credential-store]: #gcm_credential_store diff --git a/docs/img/git-for-windows-gcm-screenshot.png b/docs/img/git-for-windows-gcm-screenshot.png new file mode 100644 index 000000000..aaf60b682 Binary files /dev/null and b/docs/img/git-for-windows-gcm-screenshot.png differ diff --git a/docs/install.md b/docs/install.md index 5ae7b44d5..9fa7da4ac 100644 --- a/docs/install.md +++ b/docs/install.md @@ -151,7 +151,7 @@ manager. GCM is included with [Git for Windows][git-for-windows]. During installation you will be asked to select a credential helper, with GCM listed as the default. -![image][git-for-windows-screenshot] +![image][git-for-windows-gcm-screenshot] --- @@ -241,7 +241,7 @@ dotnet tool uninstall -g git-credential-manager [gcm-credstores]: credstores.md [gcm-wsl]: wsl.md [git-for-windows]: https://gitforwindows.org/ -[git-for-windows-screenshot]: https://user-images.githubusercontent.com/5658207/140082529-1ac133c1-0922-4a24-af03-067e27b3988b.png +[git-for-windows-gcm-screenshot]: img/git-for-windows-gcm-screenshot.png [latest-release]: https://github.com/git-ecosystem/git-credential-manager/releases/latest [linux-uninstall]: linux-fromsrc-uninstall.md [linux-validate-gpg-debian]: ./linux-validate-gpg.md#debian-package diff --git a/docs/netconfig.md b/docs/netconfig.md index cf312336f..920344f15 100644 --- a/docs/netconfig.md +++ b/docs/netconfig.md @@ -191,6 +191,22 @@ network traffic inspection tool such as [Telerik Fiddler][telerik-fiddler]. If you are using such tools please consult their documentation for trusting the proxy root certificates. +--- + +## Unsafe Remote URLs + +If you are using a remote URL that is not considered safe, such as unencrypted +HTTP (remote URLs that start with `http://`), host providers may prevent you +from authenticating with your credentials. + +In this case, you should consider using a HTTPS (starting with `https://`) +remote URL to ensure your credentials are transmitted securely. + +If you accept the risks associated with using an unsafe remote URL, you can +configure GCM to allow the use of unsafe remote URLS by setting the environment +variable [`GCM_ALLOW_UNSAFE_REMOTES`][unsafe-envar], or by using the Git +configuration option [`credential.allowUnsafeRemotes`][unsafe-config] to `true`. + [environment]: environment.md [configuration]: configuration.md [git-http-proxy]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy @@ -212,3 +228,5 @@ proxy root certificates. [git-ssl-no-verify]: https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_networking [git-http-ssl-verify]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpsslVerify [telerik-fiddler]: https://www.telerik.com/fiddler +[unsafe-envar]: environment.md#gcm_allow_unsafe_remotes +[unsafe-config]: configuration.md#credentialallowunsaferemotes diff --git a/global.json b/global.json new file mode 100644 index 000000000..5cc6b13a6 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "rollForward": "latestMajor", + "version": "8.0" + } +} + diff --git a/src/linux/Packaging.Linux/Packaging.Linux.csproj b/src/linux/Packaging.Linux/Packaging.Linux.csproj index 8b9755c78..ddfb31500 100644 --- a/src/linux/Packaging.Linux/Packaging.Linux.csproj +++ b/src/linux/Packaging.Linux/Packaging.Linux.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/src/linux/Packaging.Linux/build.sh b/src/linux/Packaging.Linux/build.sh index 6672857d2..4a77eff69 100755 --- a/src/linux/Packaging.Linux/build.sh +++ b/src/linux/Packaging.Linux/build.sh @@ -30,6 +30,10 @@ case "$i" in INSTALL_FROM_SOURCE="${i#*=}" shift # past argument=value ;; + --runtime=*) + RUNTIME="${i#*=}" + shift # past argument=value + ;; --install-prefix=*) INSTALL_PREFIX="${i#*=}" shift # past argument=value @@ -41,10 +45,14 @@ esac done # Ensure install prefix exists -if [! -d "$INSTALL_PREFIX" ]; then +if [ ! -d "$INSTALL_PREFIX" ]; then mkdir -p "$INSTALL_PREFIX" fi +if [ ! -z "$RUNTIME" ]; then + echo "Building for runtime ${RUNTIME}" +fi + # Perform pre-execution checks CONFIGURATION="${CONFIGURATION:=Debug}" if [ -z "$VERSION" ]; then @@ -56,7 +64,7 @@ PAYLOAD="$OUTDIR/payload" SYMBOLS="$OUTDIR/payload.sym" # Lay out payload -"$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" || exit 1 +"$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" --runtime="$RUNTIME" --output="$PAYLOAD" --symbol-output="$SYMBOLS" || exit 1 if [ $INSTALL_FROM_SOURCE = true ]; then echo "Installing to $INSTALL_PREFIX" @@ -79,7 +87,7 @@ if [ $INSTALL_FROM_SOURCE = true ]; then echo "Install complete." else # Pack - "$INSTALLER_SRC/pack.sh" --configuration="$CONFIGURATION" --payload="$PAYLOAD" --symbols="$SYMBOLS" --version="$VERSION" || exit 1 + "$INSTALLER_SRC/pack.sh" --configuration="$CONFIGURATION" --runtime="$RUNTIME" --payload="$PAYLOAD" --symbols="$SYMBOLS" --version="$VERSION" || exit 1 fi echo "Build of Packaging.Linux complete." diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index be6ea1579..1a8ede938 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -40,18 +40,26 @@ if [ -z $is_ci ]; then Git Credential Manager is licensed under the MIT License: https://aka.ms/gcm/license" - while true; do - read -p "Do you want to continue? [Y/n] " yn - case $yn in - [Yy]*|"") - break - ;; - [Nn]*) - exit - ;; - *) - echo "Please answer yes or no." - ;; + while true; do + # Display prompt once before reading input + printf "Do you want to continue? [Y/n] " + + # Prefer reading from the controlling terminal (TTY) when available, + # so that input works even if the script is piped (e.g. curl URL | sh) + if [ -r /dev/tty ]; then + read yn < /dev/tty + # If no TTY is available, attempt to read from standard input (stdin) + elif ! read yn; then + # If input is not possible via TTY or stdin, assume a non-interactive environment + # and abort with guidance for automated usage + echo "Interactive prompt unavailable in this environment. Use 'sh -s -- -y' for automated install." + exit 1 + fi + + case "$yn" in + [Yy]*|"") break ;; + [Nn]*) exit ;; + *) echo "Please answer yes or no." ;; esac done fi @@ -63,7 +71,7 @@ install_packages() { for package in $packages; do # Ensure we don't stomp on existing installations. - if [ ! -z $(which $package) ]; then + if type $package >/dev/null 2>&1; then continue fi @@ -181,7 +189,7 @@ case "$distribution" in fi fi ;; - fedora | centos | rhel) + fedora | centos | rhel | ol) $sudo_cmd dnf upgrade -y # Install dotnet/GCM dependencies. @@ -208,7 +216,7 @@ case "$distribution" in $sudo_cmd zypper -n update # Install dotnet/GCM dependencies. - install_packages zypper install "curl git find krb5 libicu libopenssl1_1" + install_packages zypper install "curl git find krb5 libicu" ensure_dotnet_installed ;; @@ -228,7 +236,7 @@ case "$distribution" in $sudo_cmd tdnf update -y # Install dotnet/GCM dependencies. - install_packages tdnf install "curl git krb5-libs libicu openssl-libs zlib findutils which bash" + install_packages tdnf install "curl ca-certificates git krb5-libs libicu openssl-libs zlib findutils which bash awk" ensure_dotnet_installed ;; diff --git a/src/linux/Packaging.Linux/layout.sh b/src/linux/Packaging.Linux/layout.sh index 6679c39ca..fe3a0f2b8 100755 --- a/src/linux/Packaging.Linux/layout.sh +++ b/src/linux/Packaging.Linux/layout.sh @@ -23,6 +23,17 @@ case "$i" in CONFIGURATION="${i#*=}" shift # past argument=value ;; + --output=*) + PAYLOAD="${i#*=}" + shift # past argument=value + ;; + --runtime=*) + RUNTIME="${i#*=}" + shift # past argument=value + ;; + --symbol-output=*) + SYMBOLOUT="${i#*=}" + ;; *) # unknown option ;; @@ -39,14 +50,15 @@ PROJ_OUT="$OUT/linux/Packaging.Linux" # Build parameters FRAMEWORK=net8.0 -RUNTIME=linux-x64 # Perform pre-execution checks CONFIGURATION="${CONFIGURATION:=Debug}" - -# Outputs -PAYLOAD="$PROJ_OUT/$CONFIGURATION/payload" -SYMBOLOUT="$PROJ_OUT/$CONFIGURATION/payload.sym" +if [ -z "$PAYLOAD" ]; then + die "--output was not set" +fi +if [ -z "$SYMBOLOUT" ]; then + SYMBOLOUT="$PAYLOAD.sym" +fi # Cleanup payload directory if [ -d "$PAYLOAD" ]; then @@ -69,13 +81,22 @@ fi # Publish core application executables echo "Publishing core application..." -$DOTNET_ROOT/dotnet publish "$GCM_SRC" \ - --configuration="$CONFIGURATION" \ - --framework="$FRAMEWORK" \ - --runtime="$RUNTIME" \ - --self-contained \ - -p:PublishSingleFile=true \ - --output="$(make_absolute "$PAYLOAD")" || exit 1 +if [ -z "$RUNTIME" ]; then + $DOTNET_ROOT/dotnet publish "$GCM_SRC" \ + --configuration="$CONFIGURATION" \ + --framework="$FRAMEWORK" \ + --self-contained \ + -p:PublishSingleFile=true \ + --output="$(make_absolute "$PAYLOAD")" || exit 1 +else + $DOTNET_ROOT/dotnet publish "$GCM_SRC" \ + --configuration="$CONFIGURATION" \ + --framework="$FRAMEWORK" \ + --runtime="$RUNTIME" \ + --self-contained \ + -p:PublishSingleFile=true \ + --output="$(make_absolute "$PAYLOAD")" || exit 1 +fi # Collect symbols echo "Collecting managed symbols..." diff --git a/src/linux/Packaging.Linux/pack.sh b/src/linux/Packaging.Linux/pack.sh index 14d26aee5..821d66ea1 100755 --- a/src/linux/Packaging.Linux/pack.sh +++ b/src/linux/Packaging.Linux/pack.sh @@ -28,10 +28,18 @@ case "$i" in SYMBOLS="${i#*=}" shift # past argument=value ;; + --runtime=*) + RUNTIME="${i#*=}" + shift # past argument=value + ;; --configuration=*) CONFIGURATION="${i#*=}" shift # past argument=value ;; + --output=*) + OUTPUT_ROOT="${i#*=}" + shift # past argument=value + ;; *) # unknown option ;; @@ -51,20 +59,37 @@ fi if [ -z "$SYMBOLS" ]; then die "--symbols was not set" fi +if [ -z "$OUTPUT_ROOT" ]; then + OUTPUT_ROOT="$PROJ_OUT/$CONFIGURATION" +fi -ARCH="`dpkg-architecture -q DEB_HOST_ARCH`" - -if test -z "$ARCH"; then - die "Could not determine host architecture!" +# Fall back to host architecture if no explicit runtime is given. +if test -z "$RUNTIME"; then + HOST_ARCH="`uname -m`" + + case $HOST_ARCH in + x86_64|amd64) + RUNTIME="linux-x64" + ;; + aarch64|arm64) + RUNTIME="linux-arm64" + ;; + armhf) + RUNTIME="linux-arm" + ;; + *) + die "Could not determine host architecture! ($HOST_ARCH)" + ;; + esac fi -TAROUT="$PROJ_OUT/$CONFIGURATION/tar/" -TARBALL="$TAROUT/gcm-linux_$ARCH.$VERSION.tar.gz" -SYMTARBALL="$TAROUT/gcm-linux_$ARCH.$VERSION-symbols.tar.gz" +TAROUT="$OUTPUT_ROOT/tar" +TARBALL="$TAROUT/gcm-$RUNTIME.$VERSION.tar.gz" +SYMTARBALL="$TAROUT/gcm-$RUNTIME.$VERSION-symbols.tar.gz" -DEBOUT="$PROJ_OUT/$CONFIGURATION/deb" +DEBOUT="$OUTPUT_ROOT/deb" DEBROOT="$DEBOUT/root" -DEBPKG="$DEBOUT/gcm-linux_$ARCH.$VERSION.deb" +DEBPKG="$DEBOUT/gcm-$RUNTIME.$VERSION.deb" mkdir -p "$DEBROOT" # Set full read, write, execute permissions for owner and just read and execute permissions for group and other @@ -75,7 +100,7 @@ echo "Packing Packaging.Linux..." # Cleanup any old archive files if [ -e "$TAROUT" ]; then - echo "Deleteing old archive '$TAROUT'..." + echo "Deleting old archive '$TAROUT'..." rm "$TAROUT" fi @@ -99,6 +124,22 @@ INSTALL_TO="$DEBROOT/usr/local/share/gcm-core/" LINK_TO="$DEBROOT/usr/local/bin/" mkdir -p "$DEBROOT/DEBIAN" "$INSTALL_TO" "$LINK_TO" || exit 1 +# Determine architecture for debian control file from the runtime architecture +case $RUNTIME in + linux-x64) + ARCH="amd64" + ;; + linux-arm64) + ARCH="arm64" + ;; + linux-arm) + ARCH="armhf" + ;; + *) + die "Incompatible runtime architecture given for pack.sh" + ;; +esac + # make the debian control file # this is purposefully not indented, see # https://stackoverflow.com/questions/9349616/bash-eof-in-if-statement diff --git a/src/osx/Installer.Mac/codesign.sh b/src/osx/Installer.Mac/codesign.sh index d66c8acd9..44feedb6f 100755 --- a/src/osx/Installer.Mac/codesign.sh +++ b/src/osx/Installer.Mac/codesign.sh @@ -15,32 +15,45 @@ elif [ -z "$ENTITLEMENTS_FILE" ]; then exit 1 fi +# The codesign command needs the entitlements file to be given as an absolute +# file path; relative paths can cause issues. +if [[ "${ENTITLEMENTS_FILE}" != /* ]]; then + echo "error: entitlements file argument must be an absolute path" + exit 1 +fi + echo "======== INPUTS ========" echo "Directory: $SIGN_DIR" echo "Developer ID: $DEVELOPER_ID" echo "Entitlements: $ENTITLEMENTS_FILE" echo "======== END INPUTS ========" +echo +echo "======== ENTITLEMENTS ========" +cat "$ENTITLEMENTS_FILE" +echo "======== END ENTITLEMENTS ========" +echo -cd $SIGN_DIR +cd "$SIGN_DIR" || exit 1 for f in * do - macho=$(file --mime $f | grep mach) + macho=$(file --mime "$f" | grep mach) # Runtime sign dylibs and Mach-O binaries - if [[ $f == *.dylib ]] || [ ! -z "$macho" ]; + if [[ $f == *.dylib ]] || [ -n "$macho" ]; then - echo "Runtime Signing $f" - codesign -s "$DEVELOPER_ID" $f --timestamp --force --options=runtime --entitlements $ENTITLEMENTS_FILE + echo "Signing with entitlements and hardening: $f" + codesign -s "$DEVELOPER_ID" "$f" --timestamp --force --options=runtime --entitlements "$ENTITLEMENTS_FILE" elif [ -d "$f" ]; then - echo "Signing files in subdirectory $f" - cd $f - for i in * - do - codesign -s "$DEVELOPER_ID" $i --timestamp --force - done - cd .. + echo "Signing files in subdirectory: $f" + ( + cd "$f" || exit 1 + for i in * + do + codesign -s "$DEVELOPER_ID" "$i" --timestamp --force + done + ) else - echo "Signing $f" - codesign -s "$DEVELOPER_ID" $f --timestamp --force + echo "Signing: $f" + codesign -s "$DEVELOPER_ID" "$f" --timestamp --force fi done diff --git a/src/osx/Installer.Mac/dist.sh b/src/osx/Installer.Mac/dist.sh index f26761e26..185da7248 100755 --- a/src/osx/Installer.Mac/dist.sh +++ b/src/osx/Installer.Mac/dist.sh @@ -82,7 +82,7 @@ fi # Cleanup any old package if [ -e "$DISTOUT" ]; then - echo "Deleteing old product package '$DISTOUT'..." + echo "Deleting old product package '$DISTOUT'..." rm "$DISTOUT" fi diff --git a/src/osx/Installer.Mac/pack.sh b/src/osx/Installer.Mac/pack.sh index b58f4ce5a..77c6c623e 100755 --- a/src/osx/Installer.Mac/pack.sh +++ b/src/osx/Installer.Mac/pack.sh @@ -52,7 +52,7 @@ fi # Cleanup any old component if [ -e "$PKGOUT" ]; then - echo "Deleteing old component '$PKGOUT'..." + echo "Deleting old component '$PKGOUT'..." rm "$PKGOUT" fi diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index 35472682c..286398de9 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -55,8 +55,8 @@ public bool IsSupported(InputArguments input) return false; } - // We do not support unencrypted HTTP communications to Bitbucket, - // but we report `true` here for HTTP so that we can show a helpful + // We do not recommend unencrypted HTTP communications to Bitbucket, but it is possible. + // Therefore, we report `true` here for HTTP so that we can show a helpful // error message for the user in `GetCredentialAsync`. return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && @@ -81,11 +81,14 @@ public bool IsSupported(HttpResponseMessage response) public async Task GetCredentialAsync(InputArguments input) { // We should not allow unencrypted communication and should inform the user - if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") - && BitbucketHelper.IsBitbucketOrg(input)) + if (!_context.Settings.AllowUnsafeRemotes && + StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") && + BitbucketHelper.IsBitbucketOrg(input)) { throw new Trace2Exception(_context.Trace2, - "Unencrypted HTTP is not supported for Bitbucket.org. Ensure the repository remote URL is using HTTPS."); + "Unencrypted HTTP is not recommended for Bitbucket.org. " + + "Ensure the repository remote URL is using HTTPS " + + $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); } var authModes = await GetSupportedAuthenticationModesAsync(input); diff --git a/src/shared/Core.Tests/GitStreamReaderTests.cs b/src/shared/Core.Tests/GitStreamReaderTests.cs new file mode 100644 index 000000000..bf656d102 --- /dev/null +++ b/src/shared/Core.Tests/GitStreamReaderTests.cs @@ -0,0 +1,193 @@ +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace GitCredentialManager.Tests; + +public class GitStreamReaderTests +{ + #region ReadLineAsync + + [Fact] + public async Task GitStreamReader_ReadLineAsync_LF() + { + // hello\n + // world\n + + byte[] buffer = Encoding.UTF8.GetBytes("hello\nworld\n"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = await reader.ReadLineAsync(); + string actual2 = await reader.ReadLineAsync(); + string actual3 = await reader.ReadLineAsync(); + + Assert.Equal("hello", actual1); + Assert.Equal("world", actual2); + Assert.Null(actual3); + } + + [Fact] + public async Task GitStreamReader_ReadLineAsync_CR() + { + // hello\rworld\r + + byte[] buffer = Encoding.UTF8.GetBytes("hello\rworld\r"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = await reader.ReadLineAsync(); + string actual2 = await reader.ReadLineAsync(); + + Assert.Equal("hello\rworld\r", actual1); + Assert.Null(actual2); + } + + [Fact] + public async Task GitStreamReader_ReadLineAsync_CRLF() + { + // hello\r\n + // world\r\n + + byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\r\n"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = await reader.ReadLineAsync(); + string actual2 = await reader.ReadLineAsync(); + string actual3 = await reader.ReadLineAsync(); + + Assert.Equal("hello", actual1); + Assert.Equal("world", actual2); + Assert.Null(actual3); + } + + [Fact] + public async Task GitStreamReader_ReadLineAsync_Mixed() + { + // hello\r\n + // world\rthis\n + // is\n + // a\n + // \rmixed\rnewline\r\n + // \n + // string\n + + byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\rthis\nis\na\n\rmixed\rnewline\r\n\nstring\n"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = await reader.ReadLineAsync(); + string actual2 = await reader.ReadLineAsync(); + string actual3 = await reader.ReadLineAsync(); + string actual4 = await reader.ReadLineAsync(); + string actual5 = await reader.ReadLineAsync(); + string actual6 = await reader.ReadLineAsync(); + string actual7 = await reader.ReadLineAsync(); + string actual8 = await reader.ReadLineAsync(); + + Assert.Equal("hello", actual1); + Assert.Equal("world\rthis", actual2); + Assert.Equal("is", actual3); + Assert.Equal("a", actual4); + Assert.Equal("\rmixed\rnewline", actual5); + Assert.Equal("", actual6); + Assert.Equal("string", actual7); + Assert.Null(actual8); + } + + #endregion + + #region ReadLine + + [Fact] + public void GitStreamReader_ReadLine_LF() + { + // hello\n + // world\n + + byte[] buffer = Encoding.UTF8.GetBytes("hello\nworld\n"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = reader.ReadLine(); + string actual2 = reader.ReadLine(); + string actual3 = reader.ReadLine(); + + Assert.Equal("hello", actual1); + Assert.Equal("world", actual2); + Assert.Null(actual3); + } + + [Fact] + public void GitStreamReader_ReadLine_CR() + { + // hello\rworld\r + + byte[] buffer = Encoding.UTF8.GetBytes("hello\rworld\r"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = reader.ReadLine(); + string actual2 = reader.ReadLine(); + + Assert.Equal("hello\rworld\r", actual1); + Assert.Null(actual2); + } + + [Fact] + public void GitStreamReader_ReadLine_CRLF() + { + // hello\r\n + // world\r\n + + byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\r\n"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = reader.ReadLine(); + string actual2 = reader.ReadLine(); + string actual3 = reader.ReadLine(); + + Assert.Equal("hello", actual1); + Assert.Equal("world", actual2); + Assert.Null(actual3); + } + + [Fact] + public void GitStreamReader_ReadLine_Mixed() + { + // hello\r\n + // world\rthis\n + // is\n + // a\n + // \rmixed\rnewline\r\n + // \n + // string\n + + byte[] buffer = Encoding.UTF8.GetBytes("hello\r\nworld\rthis\nis\na\n\rmixed\rnewline\r\n\nstring\n"); + using var stream = new MemoryStream(buffer); + var reader = new GitStreamReader(stream, Encoding.UTF8); + + string actual1 = reader.ReadLine(); + string actual2 = reader.ReadLine(); + string actual3 = reader.ReadLine(); + string actual4 = reader.ReadLine(); + string actual5 = reader.ReadLine(); + string actual6 = reader.ReadLine(); + string actual7 = reader.ReadLine(); + string actual8 = reader.ReadLine(); + + Assert.Equal("hello", actual1); + Assert.Equal("world\rthis", actual2); + Assert.Equal("is", actual3); + Assert.Equal("a", actual4); + Assert.Equal("\rmixed\rnewline", actual5); + Assert.Equal("", actual6); + Assert.Equal("string", actual7); + Assert.Null(actual8); + } + + #endregion +} diff --git a/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs b/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs new file mode 100644 index 000000000..0efb14471 --- /dev/null +++ b/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using GitCredentialManager.Interop.MacOS; +using static GitCredentialManager.Tests.TestUtils; + +namespace GitCredentialManager.Tests.Interop.MacOS; + +public class MacOSPreferencesTests +{ + private const string TestAppId = "com.example.gcm-test"; + private const string DefaultsPath = "/usr/bin/defaults"; + + [MacOSFact] + public async Task MacOSPreferences_ReadPreferences() + { + try + { + await SetupTestPreferencesAsync(); + + var pref = new MacOSPreferences(TestAppId); + + // Exists + string stringValue = pref.GetString("myString"); + int? intValue = pref.GetInteger("myInt"); + IDictionary dictValue = pref.GetDictionary("myDict"); + + Assert.NotNull(stringValue); + Assert.Equal("this is a string", stringValue); + Assert.NotNull(intValue); + Assert.Equal(42, intValue); + Assert.NotNull(dictValue); + Assert.Equal(2, dictValue.Count); + Assert.Equal("value1", dictValue["dict-k1"]); + Assert.Equal("value2", dictValue["dict-k2"]); + + // Does not exist + string missingString = pref.GetString("missingString"); + int? missingInt = pref.GetInteger("missingInt"); + IDictionary missingDict = pref.GetDictionary("missingDict"); + + Assert.Null(missingString); + Assert.Null(missingInt); + Assert.Null(missingDict); + } + finally + { + await CleanupTestPreferencesAsync(); + } + } + + private static async Task SetupTestPreferencesAsync() + { + // Using the defaults command set up preferences for the test app + await RunCommandAsync(DefaultsPath, $"write {TestAppId} myString \"this is a string\""); + await RunCommandAsync(DefaultsPath, $"write {TestAppId} myInt -int 42"); + await RunCommandAsync(DefaultsPath, $"write {TestAppId} myDict -dict dict-k1 value1 dict-k2 value2"); + } + + private static async Task CleanupTestPreferencesAsync() + { + // Delete the test app preferences + // defaults delete com.example.gcm-test + await RunCommandAsync(DefaultsPath, $"delete {TestAppId}"); + } +} diff --git a/src/shared/Core.Tests/Trace2MessageTests.cs b/src/shared/Core.Tests/Trace2MessageTests.cs index 82b161744..7e29a641f 100644 --- a/src/shared/Core.Tests/Trace2MessageTests.cs +++ b/src/shared/Core.Tests/Trace2MessageTests.cs @@ -54,7 +54,7 @@ public void Event_Message_Without_Snake_Case_ToJson_Creates_Expected_Json() ParameterizedMessage = "baz" }; - var expected = "{\"event\":\"error\",\"sid\":\"123\",\"thread\":\"main\",\"time\":\"0001-01-01T00:00:00+00:00\",\"file\":\"foo.cs\",\"line\":1,\"depth\":1,\"msg\":\"bar\",\"format\":\"baz\"}"; + var expected = "{\"event\":\"error\",\"sid\":\"123\",\"thread\":\"main\",\"time\":\"0001-01-01T00:00:00+00:00\",\"file\":\"foo.cs\",\"line\":1,\"depth\":1,\"msg\":\"bar\",\"fmt\":\"baz\"}"; var actual = errorMessage.ToJson(); Assert.Equal(expected, actual); diff --git a/src/shared/Core.Tests/Trace2Tests.cs b/src/shared/Core.Tests/Trace2Tests.cs index 26df5ab98..38011275d 100644 --- a/src/shared/Core.Tests/Trace2Tests.cs +++ b/src/shared/Core.Tests/Trace2Tests.cs @@ -6,25 +6,26 @@ public class Trace2Tests { [PosixTheory] [InlineData("af_unix:foo", "foo")] - [InlineData("af_unix:stream:foo-bar", "foo-bar")] - [InlineData("af_unix:dgram:foo-bar-baz", "foo-bar-baz")] + [InlineData("af_unix:foo/bar", "foo/bar")] + [InlineData("af_unix:stream:foo/bar", "foo/bar")] + [InlineData("af_unix:dgram:foo/bar/baz", "foo/bar/baz")] public void TryGetPipeName_Posix_Returns_Expected_Value(string input, string expected) { var isSuccessful = Trace2.TryGetPipeName(input, out var actual); Assert.True(isSuccessful); - Assert.Matches(actual, expected); + Assert.Equal(actual, expected); } [WindowsTheory] [InlineData("\\\\.\\pipe\\git-foo", "git-foo")] [InlineData("\\\\.\\pipe\\git-foo-bar", "git-foo-bar")] - [InlineData("\\\\.\\pipe\\foo\\git-bar", "git-bar")] + [InlineData("\\\\.\\pipe\\foo\\git-bar", "foo\\git-bar")] public void TryGetPipeName_Windows_Returns_Expected_Value(string input, string expected) { var isSuccessful = Trace2.TryGetPipeName(input, out var actual); Assert.True(isSuccessful); - Assert.Matches(actual, expected); + Assert.Equal(expected, actual); } } diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs index 712db32e1..d3ef1dbf6 100644 --- a/src/shared/Core/CommandContext.cs +++ b/src/shared/Core/CommandContext.cs @@ -131,7 +131,7 @@ public CommandContext() gitPath, FileSystem.GetCurrentDirectory() ); - Settings = new Settings(Environment, Git); + Settings = new MacOSSettings(Environment, Git, Trace); } else if (PlatformUtils.IsLinux()) { diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 210c991bc..4777b0cf8 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -16,6 +16,7 @@ public static class Constants public const string GcmDataDirectoryName = ".gcm"; + public const string MacOSBundleId = "git-credential-manager"; public static readonly Guid DevBoxPartnerId = new("e3171dd9-9a5f-e5be-b36c-cc7c4f3f3bcf"); /// @@ -38,6 +39,7 @@ public static class CredentialStoreNames public const string SecretService = "secretservice"; public const string Plaintext = "plaintext"; public const string Cache = "cache"; + public const string None = "none"; } public static class RegexPatterns @@ -119,6 +121,7 @@ public static class EnvironmentVariables public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME"; public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; + public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES"; } public static class Http @@ -163,6 +166,7 @@ public static class Credential public const string MsAuthUseDefaultAccount = "msauthUseDefaultAccount"; public const string GuiSoftwareRendering = "guiSoftwareRendering"; public const string GpgPassStorePath = "gpgPassStorePath"; + public const string AllowUnsafeRemotes = "allowUnsafeRemotes"; public const string OAuthAuthenticationModes = "oauthAuthModes"; public const string OAuthClientId = "oauthClientId"; @@ -226,6 +230,7 @@ public static class HelpUrls public const string GcmAutoDetect = "https://aka.ms/gcm/autodetect"; public const string GcmDefaultAccount = "https://aka.ms/gcm/defaultaccount"; public const string GcmMultipleUsers = "https://aka.ms/gcm/multipleusers"; + public const string GcmUnsafeRemotes = "https://aka.ms/gcm/unsaferemotes"; } private static Version _gcmVersion; diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index 83f915d1e..11dc83818 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -100,6 +100,10 @@ private void EnsureBackingStore() _backingStore = new PlaintextCredentialStore(_context.FileSystem, plainStoreRoot, ns); break; + case StoreNames.None: + _backingStore = new NullCredentialStore(); + break; + default: var sb = new StringBuilder(); sb.AppendLine(string.IsNullOrWhiteSpace(credStoreName) @@ -168,6 +172,9 @@ private static void AppendAvailableStoreList(StringBuilder sb) sb.AppendFormat(" {1,-13} : store credentials in plain-text files (UNSECURE){0}", Environment.NewLine, StoreNames.Plaintext); + + sb.AppendFormat(" {1, -13} : disable internal credential storage{0}", + Environment.NewLine, StoreNames.None); } private void ValidateWindowsCredentialManager() diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 447e465d5..19e1d6733 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -54,6 +54,17 @@ public override async Task GenerateCredentialAsync(InputArguments i { ThrowIfDisposed(); + // We only want to *warn* about HTTP remotes for the generic provider because it supports all protocols + // and, historically, we never blocked HTTP remotes in this provider. + // The user can always set the 'GCM_ALLOW_UNSAFE' setting to silence the warning. + if (!Context.Settings.AllowUnsafeRemotes && + StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + { + Context.Streams.Error.WriteLine( + "warning: use of unencrypted HTTP remote URLs is not recommended; " + + $"see {Constants.HelpUrls.GcmUnsafeRemotes} for more information."); + } + Uri uri = input.GetRemoteUri(); // Determine the if the host supports Windows Integration Authentication (WIA) or OAuth @@ -150,7 +161,7 @@ private async Task GetOAuthAccessToken(Uri remoteUri, string userNa // Store new refresh token if we have been given one if (!string.IsNullOrWhiteSpace(refreshResult.RefreshToken)) { - Context.CredentialStore.AddOrUpdate(refreshService, refreshToken.Account, refreshToken.Password); + Context.CredentialStore.AddOrUpdate(refreshService, refreshToken.Account, refreshResult.RefreshToken); } // Return the new access token diff --git a/src/shared/Core/GitStreamReader.cs b/src/shared/Core/GitStreamReader.cs new file mode 100644 index 000000000..6512b2efc --- /dev/null +++ b/src/shared/Core/GitStreamReader.cs @@ -0,0 +1,70 @@ +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GitCredentialManager; + +/// +/// StreamReader that does NOT consider a lone carriage-return as a new-line character, +/// only a line-feed or carriage-return immediately followed by a line-feed. +/// +/// The only major operating system that uses a lone carriage-return as a new-line character +/// is the classic Macintosh OS (before OS X), which is not supported by Git. +/// +public class GitStreamReader : StreamReader +{ + public GitStreamReader(Stream stream, Encoding encoding) : base(stream, encoding) { } + + public override string ReadLine() + { +#if NETFRAMEWORK + return ReadLineAsync().ConfigureAwait(false).GetAwaiter().GetResult(); +#else + return ReadLineAsync(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); +#endif + } + +#if NETFRAMEWORK + public override async Task ReadLineAsync() +#else + public override async ValueTask ReadLineAsync(CancellationToken cancellationToken) +#endif + { + int nr; + var sb = new StringBuilder(); + var buffer = new char[1]; + bool lastWasCR = false; + + while ((nr = await base.ReadAsync(buffer, 0, 1).ConfigureAwait(false)) > 0) + { + char c = buffer[0]; + + // Only treat a line-feed as a new-line character. + // Carriage-returns alone are NOT considered new-line characters. + if (c == '\n') + { + if (lastWasCR) + { + // If the last character was a carriage-return we should remove it from the string builder + // since together with this line-feed it is considered a new-line character. + sb.Length--; + } + + // We have a new-line character, so we should stop reading. + break; + } + + lastWasCR = c == '\r'; + + sb.Append(c); + } + + if (sb.Length == 0 && nr == 0) + { + return null; + } + + return sb.ToString(); + } +} diff --git a/src/shared/Core/Interop/Linux/SecretServiceCollection.cs b/src/shared/Core/Interop/Linux/SecretServiceCollection.cs index 093baf5c3..0d6342af1 100644 --- a/src/shared/Core/Interop/Linux/SecretServiceCollection.cs +++ b/src/shared/Core/Interop/Linux/SecretServiceCollection.cs @@ -66,7 +66,7 @@ private unsafe IEnumerable Enumerate(string service, string account secService, ref schema, queryAttrs, - SecretSearchFlags.SECRET_SEARCH_UNLOCK, + SecretSearchFlags.SECRET_SEARCH_UNLOCK | SecretSearchFlags.SECRET_SEARCH_ALL, IntPtr.Zero, out error); diff --git a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs index b024be129..9335e136d 100644 --- a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs +++ b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs @@ -302,35 +302,18 @@ private static string GetStringAttribute(IntPtr dict, IntPtr key) return null; } - IntPtr buffer = IntPtr.Zero; - try + if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero) { - if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero) + if (CFGetTypeID(value) == CFStringGetTypeID()) { - if (CFGetTypeID(value) == CFStringGetTypeID()) - { - int stringLength = (int)CFStringGetLength(value); - int bufferSize = stringLength + 1; - buffer = Marshal.AllocHGlobal(bufferSize); - if (CFStringGetCString(value, buffer, bufferSize, CFStringEncoding.kCFStringEncodingUTF8)) - { - return Marshal.PtrToStringAuto(buffer, stringLength); - } - } - - if (CFGetTypeID(value) == CFDataGetTypeID()) - { - int length = CFDataGetLength(value); - IntPtr ptr = CFDataGetBytePtr(value); - return Marshal.PtrToStringAuto(ptr, length); - } + return CFStringToString(value); } - } - finally - { - if (buffer != IntPtr.Zero) + + if (CFGetTypeID(value) == CFDataGetTypeID()) { - Marshal.FreeHGlobal(buffer); + int length = CFDataGetLength(value); + IntPtr ptr = CFDataGetBytePtr(value); + return Marshal.PtrToStringAuto(ptr, length); } } diff --git a/src/shared/Core/Interop/MacOS/MacOSPreferences.cs b/src/shared/Core/Interop/MacOS/MacOSPreferences.cs new file mode 100644 index 000000000..f866b30a8 --- /dev/null +++ b/src/shared/Core/Interop/MacOS/MacOSPreferences.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using GitCredentialManager.Interop.MacOS.Native; +using static GitCredentialManager.Interop.MacOS.Native.CoreFoundation; + +namespace GitCredentialManager.Interop.MacOS; + +public class MacOSPreferences +{ + private readonly string _appId; + + public MacOSPreferences(string appId) + { + EnsureArgument.NotNull(appId, nameof(appId)); + + _appId = appId; + } + + /// + /// Return a typed value from the app preferences. + /// + /// Preference name. + /// Thrown if the preference is not a string. + /// + /// or null if the preference with the given key does not exist. + /// + public string GetString(string key) + { + return TryGet(key, CFStringToString, out string value) + ? value + : null; + } + + /// + /// Return a typed value from the app preferences. + /// + /// Preference name. + /// Thrown if the preference is not an integer. + /// + /// or null if the preference with the given key does not exist. + /// + public int? GetInteger(string key) + { + return TryGet(key, CFNumberToInt32, out int value) + ? value + : null; + } + + /// + /// Return a typed value from the app preferences. + /// + /// Preference name. + /// Thrown if the preference is not a dictionary. + /// + /// or null if the preference with the given key does not exist. + /// + public IDictionary GetDictionary(string key) + { + return TryGet(key, CFDictionaryToDictionary, out IDictionary value) + ? value + : null; + } + + private bool TryGet(string key, Func converter, out T value) + { + IntPtr cfValue = IntPtr.Zero; + IntPtr keyPtr = IntPtr.Zero; + IntPtr appIdPtr = CreateAppIdPtr(); + + try + { + keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, CFStringEncoding.kCFStringEncodingUTF8); + cfValue = CFPreferencesCopyAppValue(keyPtr, appIdPtr); + + if (cfValue == IntPtr.Zero) + { + value = default; + return false; + } + + value = converter(cfValue); + return true; + } + finally + { + if (cfValue != IntPtr.Zero) CFRelease(cfValue); + if (keyPtr != IntPtr.Zero) CFRelease(keyPtr); + if (appIdPtr != IntPtr.Zero) CFRelease(appIdPtr); + } + } + + private IntPtr CreateAppIdPtr() + { + return CFStringCreateWithCString(IntPtr.Zero, _appId, CFStringEncoding.kCFStringEncodingUTF8); + } +} diff --git a/src/shared/Core/Interop/MacOS/MacOSSettings.cs b/src/shared/Core/Interop/MacOS/MacOSSettings.cs new file mode 100644 index 000000000..3ef2c8247 --- /dev/null +++ b/src/shared/Core/Interop/MacOS/MacOSSettings.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace GitCredentialManager.Interop.MacOS +{ + /// + /// Reads settings from Git configuration, environment variables, and defaults from the system. + /// + public class MacOSSettings : Settings + { + private readonly ITrace _trace; + + public MacOSSettings(IEnvironment environment, IGit git, ITrace trace) + : base(environment, git) + { + EnsureArgument.NotNull(trace, nameof(trace)); + _trace = trace; + + PlatformUtils.EnsureMacOS(); + } + + protected override bool TryGetExternalDefault(string section, string scope, string property, out string value) + { + value = null; + + try + { + // Check for app default preferences for our bundle ID. + // Defaults can be deployed system administrators via device management profiles. + var prefs = new MacOSPreferences(Constants.MacOSBundleId); + IDictionary dict = prefs.GetDictionary("configuration"); + + if (dict is null) + { + // No configuration key exists + return false; + } + + // Wrap the raw dictionary in one configured with the Git configuration key comparer. + // This means we can use the same key comparison rules as Git in our configuration plist dict, + // That is, sections and names are insensitive to case, but the scope is case-sensitive. + var config = new Dictionary(dict, GitConfigurationKeyComparer.Instance); + + string name = string.IsNullOrWhiteSpace(scope) + ? $"{section}.{property}" + : $"{section}.{scope}.{property}"; + + if (!config.TryGetValue(name, out value)) + { + // No property exists + return false; + } + + _trace.WriteLine($"Default setting found in app preferences: {name}={value}"); + return true; + } + catch (Exception ex) + { + // Reading defaults is not critical to the operation of the application + // so we can ignore any errors and just log the failure. + _trace.WriteLine("Failed to read default setting from app preferences."); + _trace.WriteException(ex); + return false; + } + } + } +} diff --git a/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs b/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs index 0f32a383b..9cab2ca8f 100644 --- a/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs +++ b/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using static GitCredentialManager.Interop.MacOS.Native.LibSystem; @@ -55,6 +56,9 @@ public static extern void CFDictionaryAddValue( public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, long numBytes, CFStringEncoding encoding, bool isExternalRepresentation); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, CFStringEncoding encoding); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern long CFStringGetLength(IntPtr theString); @@ -82,15 +86,130 @@ public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int CFArrayGetTypeID(); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFNumberGetTypeID(); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr CFDataGetBytePtr(IntPtr theData); [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int CFDataGetLength(IntPtr theData); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFPreferencesCopyAppValue(IntPtr key, IntPtr appID); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern bool CFNumberGetValue(IntPtr number, CFNumberType theType, out IntPtr valuePtr); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFDictionaryGetKeysAndValues(IntPtr theDict, IntPtr[] keys, IntPtr[] values); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern long CFDictionaryGetCount(IntPtr theDict); + + public static string CFStringToString(IntPtr cfString) + { + if (cfString == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(cfString)); + } + + if (CFGetTypeID(cfString) != CFStringGetTypeID()) + { + throw new InvalidOperationException("Object is not a CFString."); + } + + long length = CFStringGetLength(cfString); + IntPtr buffer = Marshal.AllocHGlobal((int)length + 1); + + try + { + if (!CFStringGetCString(cfString, buffer, length + 1, CFStringEncoding.kCFStringEncodingUTF8)) + { + throw new InvalidOperationException("Failed to convert CFString to C string."); + } + + return Marshal.PtrToStringAnsi(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public static int CFNumberToInt32(IntPtr cfNumber) + { + if (cfNumber == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(cfNumber)); + } + + if (CFGetTypeID(cfNumber) != CFNumberGetTypeID()) + { + throw new InvalidOperationException("Object is not a CFNumber."); + } + + if (!CFNumberGetValue(cfNumber, CFNumberType.kCFNumberIntType, out IntPtr valuePtr)) + { + throw new InvalidOperationException("Failed to convert CFNumber to Int32."); + } + + return valuePtr.ToInt32(); + } + + public static IDictionary CFDictionaryToDictionary(IntPtr cfDict) + { + if (cfDict == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(cfDict)); + } + + if (CFGetTypeID(cfDict) != CFDictionaryGetTypeID()) + { + throw new InvalidOperationException("Object is not a CFDictionary."); + } + + int count = (int)CFDictionaryGetCount(cfDict); + var keys = new IntPtr[count]; + var values = new IntPtr[count]; + + CFDictionaryGetKeysAndValues(cfDict, keys, values); + + var dict = new Dictionary(capacity: count); + for (int i = 0; i < count; i++) + { + string keyStr = CFStringToString(keys[i])!; + string valueStr = CFStringToString(values[i]); + + dict[keyStr] = valueStr; + } + + return dict; + } } public enum CFStringEncoding { kCFStringEncodingUTF8 = 0x08000100, } + + public enum CFNumberType + { + kCFNumberSInt8Type = 1, + kCFNumberSInt16Type = 2, + kCFNumberSInt32Type = 3, + kCFNumberSInt64Type = 4, + kCFNumberFloat32Type = 5, + kCFNumberFloat64Type = 6, + kCFNumberCharType = 7, + kCFNumberShortType = 8, + kCFNumberIntType = 9, + kCFNumberLongType = 10, + kCFNumberLongLongType = 11, + kCFNumberFloatType = 12, + kCFNumberDoubleType = 13, + kCFNumberCFIndexType = 14, + kCFNumberNSIntegerType = 15, + kCFNumberCGFloatType = 16 + } } diff --git a/src/shared/Core/NullCredentialStore.cs b/src/shared/Core/NullCredentialStore.cs new file mode 100644 index 000000000..fac92f47c --- /dev/null +++ b/src/shared/Core/NullCredentialStore.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace GitCredentialManager; + +/// +/// Credential store that does nothing. This is useful when you want to disable internal credential storage +/// and only use another helper configured in Git to store credentials. +/// +public class NullCredentialStore : ICredentialStore +{ + public IList GetAccounts(string service) => Array.Empty(); + + public ICredential Get(string service, string account) => null; + + public void AddOrUpdate(string service, string account, string secret) { } + + public bool Remove(string service, string account) => false; +} diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index 2aa71edf4..0e24ce9a3 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -189,6 +189,11 @@ public interface ISettings : IDisposable /// bool UseSoftwareRendering { get; } + /// + /// Permit the use of unsafe remotes URLs such as regular HTTP. + /// + bool AllowUnsafeRemotes { get; } + /// /// Get TRACE2 settings. /// @@ -580,6 +585,12 @@ public bool UseSoftwareRendering } } + public bool AllowUnsafeRemotes => + TryGetSetting(KnownEnvars.GcmAllowUnsafeRemotes, + KnownGitCfg.Credential.SectionName, + KnownGitCfg.Credential.AllowUnsafeRemotes, + out string str) && str.ToBooleanyOrDefault(false); + public Trace2Settings GetTrace2Settings() { var settings = new Trace2Settings(); diff --git a/src/shared/Core/StandardStreams.cs b/src/shared/Core/StandardStreams.cs index d0b3042b0..45f9f6cc7 100644 --- a/src/shared/Core/StandardStreams.cs +++ b/src/shared/Core/StandardStreams.cs @@ -39,7 +39,7 @@ public TextReader In { if (_stdIn == null) { - _stdIn = new StreamReader(Console.OpenStandardInput(), EncodingEx.UTF8NoBom); + _stdIn = new GitStreamReader(Console.OpenStandardInput(), EncodingEx.UTF8NoBom); } return _stdIn; diff --git a/src/shared/Core/Trace2.cs b/src/shared/Core/Trace2.cs index d8eba64b5..535812ea8 100644 --- a/src/shared/Core/Trace2.cs +++ b/src/shared/Core/Trace2.cs @@ -480,13 +480,16 @@ protected override void ReleaseManagedResources() internal static bool TryGetPipeName(string eventTarget, out string name) { // Use prefixes to determine whether target is a named pipe/socket - if (eventTarget.Contains("af_unix:", StringComparison.OrdinalIgnoreCase) || - eventTarget.Contains("\\\\.\\pipe\\", StringComparison.OrdinalIgnoreCase) || - eventTarget.Contains("/./pipe/", StringComparison.OrdinalIgnoreCase)) + if (eventTarget.StartsWith("af_unix:", StringComparison.OrdinalIgnoreCase) || + eventTarget.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase) || + eventTarget.StartsWith("//./pipe/", StringComparison.OrdinalIgnoreCase)) { name = PlatformUtils.IsWindows() - ? eventTarget.TrimUntilLastIndexOf("\\") - : eventTarget.TrimUntilLastIndexOf(":"); + ? eventTarget.Replace('/', '\\') + .TrimUntilIndexOf(@"\\.\pipe\") + : eventTarget.Replace("af_unix:dgram:", "") + .Replace("af_unix:stream:", "") + .Replace("af_unix:", ""); return true; } diff --git a/src/shared/Core/Trace2Message.cs b/src/shared/Core/Trace2Message.cs index cbbe48288..14327031f 100644 --- a/src/shared/Core/Trace2Message.cs +++ b/src/shared/Core/Trace2Message.cs @@ -409,7 +409,7 @@ public class ErrorMessage : Trace2Message [JsonPropertyOrder(8)] public string Message { get; set; } - [JsonPropertyName("format")] + [JsonPropertyName("fmt")] [JsonPropertyOrder(9)] public string ParameterizedMessage { get; set; } diff --git a/src/shared/DotnetTool/dotnet-tool.nuspec b/src/shared/DotnetTool/dotnet-tool.nuspec index cf9ba7444..35f81ebc9 100644 --- a/src/shared/DotnetTool/dotnet-tool.nuspec +++ b/src/shared/DotnetTool/dotnet-tool.nuspec @@ -6,13 +6,12 @@ Secure, cross-platform Git credential storage with authentication to Azure Repos, GitHub, and other popular Git hosting services. git-credential-manager images\icon.png - https://raw.githubusercontent.com/git-ecosystem/git-credential-manager/main/assets/gcm-transparent.png - - + + diff --git a/src/shared/DotnetTool/layout.ps1 b/src/shared/DotnetTool/layout.ps1 new file mode 100644 index 000000000..ca9b13011 --- /dev/null +++ b/src/shared/DotnetTool/layout.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Lays out the .NET tool package directory. + +.PARAMETER Configuration + Build configuration (Debug/Release). Defaults to Debug. + +.PARAMETER Output + Root output directory for the nupkg layout. If omitted: + out/shared/DotnetTool/nupkg/ + +.EXAMPLE + pwsh ./layout.ps1 -Configuration Release + +.EXAMPLE + pwsh ./layout.ps1 -Output C:\temp\tool-layout + +#> + +[CmdletBinding()] +param( + [string]$Configuration = "Debug", + [string]$Output +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Make-Absolute { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + if ([System.IO.Path]::IsPathRooted($Path)) { return $Path } + return (Join-Path -Path (Get-Location) -ChildPath $Path) +} + +Write-Host "Starting layout..." -ForegroundColor Cyan + +# Directories +$ScriptDir = $PSScriptRoot +$Root = (Resolve-Path (Join-Path $ScriptDir "..\..\..")).Path +$Src = Join-Path $Root "src" +$Out = Join-Path $Root "out" +$DotnetToolRel = "shared/DotnetTool" +$GcmSrc = Join-Path $Src "shared\Git-Credential-Manager" +$ProjOut = Join-Path $Out $DotnetToolRel + +$Framework = "net8.0" + +if (-not $Output -or $Output.Trim() -eq "") { + $Output = Join-Path $ProjOut "nupkg\$Configuration" +} + +$ImgOut = Join-Path $Output "images" +$BinOut = Join-Path $Output "tools\$Framework\any" + +# Cleanup previous layout +if (Test-Path $Output) { + Write-Host "Cleaning existing output directory '$Output'..." + Remove-Item -Force -Recurse $Output +} + +# Recreate directories +$null = New-Item -ItemType Directory -Path $BinOut -Force +$null = New-Item -ItemType Directory -Path $ImgOut -Force + +# Determine DOTNET_ROOT if not set +if (-not $env:DOTNET_ROOT -or $env:DOTNET_ROOT.Trim() -eq "") { + $dotnetCmd = Get-Command dotnet -ErrorAction Stop + $env:DOTNET_ROOT = Split-Path -Parent $dotnetCmd.Source +} + +Write-Host "Publishing core application..." +& "$env:DOTNET_ROOT/dotnet" publish $GcmSrc ` + --configuration $Configuration ` + --framework $Framework ` + --output (Make-Absolute $BinOut) ` + -p:UseAppHost=false + +if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet publish failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "Copying package configuration file..." +Copy-Item -Path (Join-Path $Src "$DotnetToolRel\DotnetToolSettings.xml") -Destination $BinOut -Force + +Write-Host "Copying images..." +Copy-Item -Path (Join-Path $Src "$DotnetToolRel\icon.png") -Destination $ImgOut -Force + +Write-Host "Layout complete." -ForegroundColor Green diff --git a/src/shared/DotnetTool/layout.sh b/src/shared/DotnetTool/layout.sh deleted file mode 100755 index f5244dbbd..000000000 --- a/src/shared/DotnetTool/layout.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash -make_absolute () { - case "$1" in - /*) - echo "$1" - ;; - *) - echo "$PWD/$1" - ;; - esac -} - -##################################################################### -# Lay out -##################################################################### -# Parse script arguments -for i in "$@" -do -case "$i" in - --configuration=*) - CONFIGURATION="${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" -DOTNET_TOOL="shared/DotnetTool" -PROJ_OUT="$OUT/$DOTNET_TOOL" - -CONFIGURATION="${CONFIGURATION:=Debug}" - -# Build parameters -FRAMEWORK=net8.0 - -# Outputs -OUTDIR="$PROJ_OUT/nupkg/$CONFIGURATION" -IMGOUT="$OUTDIR/images" -PAYLOAD="$OUTDIR/payload" -SYMBOLOUT="$OUTDIR/payload.sym" - -# Cleanup output directory -if [ -d "$OUTDIR" ]; then - echo "Cleaning existing output directory '$OUTDIR'..." - rm -rf "$OUTDIR" -fi - -# Ensure output directories exist -mkdir -p "$PAYLOAD" "$SYMBOLOUT" "$IMGOUT" - -if [ -z "$DOTNET_ROOT" ]; then - DOTNET_ROOT="$(dirname $(which dotnet))" -fi - -# Publish core application executables -echo "Publishing core application..." -$DOTNET_ROOT/dotnet publish "$GCM_SRC" \ - --configuration="$CONFIGURATION" \ - --framework="$FRAMEWORK" \ - --output="$(make_absolute "$PAYLOAD")" \ - -p:UseAppHost=false || exit 1 - -# Collect symbols -echo "Collecting managed symbols..." -mv "$PAYLOAD"/*.pdb "$SYMBOLOUT" || exit 1 - -# Copy DotnetToolSettings.xml file -echo "Copying out package configuration files..." -cp "$SRC/$DOTNET_TOOL/DotnetToolSettings.xml" "$PAYLOAD/" - -# Copy package icon image -echo "Copying images..." -cp "$SRC/$DOTNET_TOOL/icon.png" "$IMGOUT" || exit 1 - -echo "Build complete." diff --git a/src/shared/DotnetTool/pack.ps1 b/src/shared/DotnetTool/pack.ps1 new file mode 100644 index 000000000..6842d030a --- /dev/null +++ b/src/shared/DotnetTool/pack.ps1 @@ -0,0 +1,95 @@ +<# +.SYNOPSIS + Creates the NuGet package for the .NET tool. + +.PARAMETER Configuration + Build configuration (Debug/Release). Defaults to Debug. + +.PARAMETER Version + Package version (required). + +.PARAMETER PackageRoot + Root of the pre-laid-out package structure (from layout). Defaults to: + out/shared/DotnetTool/nupkg/ + +.PARAMETER Output + Optional directory for the produced .nupkg/.snupkg. If omitted NuGet chooses. + +.EXAMPLE + pwsh ./pack.ps1 -Version 2.0.123-beta + +.EXAMPLE + pwsh ./pack.ps1 -Configuration Release -Version 2.1.0 -Output C:\pkgs + +#> + +[CmdletBinding()] +param( + [string]$Configuration = "Debug", + [Parameter(Mandatory = $true)] + [string]$Version, + [string]$PackageRoot, + [string]$Output +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Write-Host "Starting pack..." -ForegroundColor Cyan + +# Directories +$ScriptDir = $PSScriptRoot +$Root = (Resolve-Path (Join-Path $ScriptDir "..\..\..")).Path +$Src = Join-Path $Root "src" +$Out = Join-Path $Root "out" +$DotnetToolRel = "shared\DotnetTool" +$NuspecFile = Join-Path $Src "$DotnetToolRel\dotnet-tool.nuspec" + +if (-not (Test-Path $NuspecFile)) { + Write-Error "Could not locate nuspec file at '$NuspecFile'" + exit 1 +} + +if (-not $PackageRoot -or $PackageRoot.Trim() -eq "") { + $PackageRoot = Join-Path $Out "$DotnetToolRel\nupkg\$Configuration" +} + +if (-not (Test-Path $PackageRoot)) { + Write-Error "Package root '$PackageRoot' does not exist. Run layout.ps1 first." + exit 1 +} + +# Locate nuget +$nugetCmd = Get-Command nuget -ErrorAction SilentlyContinue +if (-not $nugetCmd) { + Write-Error "nuget CLI not found in PATH (install: https://www.nuget.org/downloads)" + exit 1 +} +$nugetExe = $nugetCmd.Source + +Write-Host "Creating .NET tool package..." + +$packArgs = @( + "pack", "$NuspecFile", + "-Properties", "Configuration=$Configuration", + "-Version", $Version, + "-Symbols", "-SymbolPackageFormat", "snupkg", + "-BasePath", "$PackageRoot" +) + +if ($Output -and $Output.Trim() -ne "") { + if (-not (Test-Path $Output)) { + Write-Host "Creating output directory '$Output'..." + New-Item -ItemType Directory -Force -Path $Output | Out-Null + } + $packArgs += @("-OutputDirectory", "$Output") +} + +& $nugetExe @packArgs + +if ($LASTEXITCODE -ne 0) { + Write-Error "nuget pack failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host ".NET tool pack complete." -ForegroundColor Green diff --git a/src/shared/DotnetTool/pack.sh b/src/shared/DotnetTool/pack.sh deleted file mode 100755 index 5b2eaf8dc..000000000 --- a/src/shared/DotnetTool/pack.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -die () { - echo "$*" >&2 - exit 1 -} - -# Parse script arguments -for i in "$@" -do -case "$i" in - --configuration=*) - CONFIGURATION="${i#*=}" - shift # past argument=value - ;; - --version=*) - VERSION="${i#*=}" - shift # past argument=value - ;; - --publish-dir=*) - PUBLISH_DIR="${i#*=}" - shift # past argument=value - ;; - *) - # unknown option - ;; -esac -done - -CONFIGURATION="${CONFIGURATION:=Debug}" -if [ -z "$VERSION" ]; then - die "--version was not set" -fi - -# Directories -THISDIR="$( cd "$(dirname "$0")" ; pwd -P )" -ROOT="$( cd "$THISDIR"/../../.. ; pwd -P )" -SRC="$ROOT/src" -OUT="$ROOT/out" -DOTNET_TOOL="shared/DotnetTool" - -if [ -z "$PUBLISH_DIR" ]; then - PUBLISH_DIR="$OUT/$DOTNET_TOOL/nupkg/$CONFIGURATION" -fi - -echo "Creating dotnet tool package..." - -dotnet pack "$SRC/$DOTNET_TOOL/DotnetTool.csproj" \ - /p:Configuration="$CONFIGURATION" \ - /p:PackageVersion="$VERSION" \ - /p:PublishDir="$PUBLISH_DIR/" - -echo "Dotnet tool pack complete." diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj index 2b594e3eb..8c469897e 100644 --- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj +++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj @@ -1,11 +1,10 @@ - + Exe net8.0 net472;net8.0 - win-x86;osx-x64;linux-x64;osx-arm64 - x86 + win-x86;win-x64;win-arm64;osx-x64;linux-x64;osx-arm64;linux-arm64;linux-arm git-credential-manager GitCredentialManager $(RepoAssetsPath)gcmicon.ico diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 918e859a0..21d29f651 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -285,10 +285,13 @@ public virtual Task EraseCredentialAsync(InputArguments input) ThrowIfDisposed(); // We should not allow unencrypted communication and should inform the user - if (StringComparer.OrdinalIgnoreCase.Equals(remoteUri.Scheme, "http")) + if (!_context.Settings.AllowUnsafeRemotes && + StringComparer.OrdinalIgnoreCase.Equals(remoteUri.Scheme, "http")) { throw new Trace2Exception(_context.Trace2, - "Unencrypted HTTP is not supported for GitHub. Ensure the repository remote URL is using HTTPS."); + "Unencrypted HTTP is not recommended for GitHub. " + + "Ensure the repository remote URL is using HTTPS " + + $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); } string service = GetServiceName(remoteUri); diff --git a/src/shared/GitLab/GitLabConstants.cs b/src/shared/GitLab/GitLabConstants.cs index a686ece7a..69f1f9b9e 100644 --- a/src/shared/GitLab/GitLabConstants.cs +++ b/src/shared/GitLab/GitLabConstants.cs @@ -10,7 +10,6 @@ public static class GitLabConstants // Owned by https://gitlab.com/gitcredentialmanager public const string OAuthClientId = "172b9f227872b5dde33f4d9b1db06a6a5515ae79508e7a00c973c85ce490671e"; - public const string OAuthClientSecret = "7da92770d1447508601e4ba026bc5eb655c8268e818cd609889cc9bae2023f39"; public static readonly Uri OAuthRedirectUri = new Uri("http://127.0.0.1/"); // https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow diff --git a/src/shared/GitLab/GitLabHostProvider.cs b/src/shared/GitLab/GitLabHostProvider.cs index eda6e2f0f..6cda3c0e1 100644 --- a/src/shared/GitLab/GitLabHostProvider.cs +++ b/src/shared/GitLab/GitLabHostProvider.cs @@ -95,10 +95,13 @@ public override async Task GenerateCredentialAsync(InputArguments i ThrowIfDisposed(); // We should not allow unencrypted communication and should inform the user - if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + if (!Context.Settings.AllowUnsafeRemotes && + StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) { throw new Trace2Exception(Context.Trace2, - "Unencrypted HTTP is not supported for GitHub. Ensure the repository remote URL is using HTTPS."); + "Unencrypted HTTP is not recommended for GitLab. " + + "Ensure the repository remote URL is using HTTPS " + + $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); } Uri remoteUri = input.GetRemoteUri(); diff --git a/src/shared/GitLab/GitLabOAuth2Client.cs b/src/shared/GitLab/GitLabOAuth2Client.cs index 3b146aaeb..ba72f5b41 100644 --- a/src/shared/GitLab/GitLabOAuth2Client.cs +++ b/src/shared/GitLab/GitLabOAuth2Client.cs @@ -59,7 +59,8 @@ private static string GetClientSecret(ISettings settings) return clientSecret; } - return GitLabConstants.OAuthClientSecret; + // no secret necessary + return null; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 1d5c649d0..525704886 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -59,7 +59,7 @@ public bool IsSupported(InputArguments input) return false; } - // We do not support unencrypted HTTP communications to Azure Repos, + // We do not recommend unencrypted HTTP communications to Azure Repos, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. return input.TryGetHostAndPort(out string hostName, out _) @@ -208,16 +208,22 @@ protected override void ReleaseManagedResources() base.ReleaseManagedResources(); } - private async Task GeneratePersonalAccessTokenAsync(InputArguments input) + private void ThrowIfUnsafeRemote(InputArguments input) { - ThrowIfDisposed(); - - // We should not allow unencrypted communication and should inform the user - if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + if (!_context.Settings.AllowUnsafeRemotes && + StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) { throw new Trace2Exception(_context.Trace2, - "Unencrypted HTTP is not supported for Azure Repos. Ensure the repository remote URL is using HTTPS."); + "Unencrypted HTTP is not recommended for Azure Repos. " + + "Ensure the repository remote URL is using HTTPS " + + $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); } + } + + private async Task GeneratePersonalAccessTokenAsync(InputArguments input) + { + ThrowIfDisposed(); + ThrowIfUnsafeRemote(input); Uri remoteUserUri = input.GetRemoteUri(includeUser: true); Uri orgUri = UriHelpers.CreateOrganizationUri(remoteUserUri, out _); @@ -257,16 +263,11 @@ private async Task GeneratePersonalAccessTokenAsync(InputArguments private async Task GetAzureAccessTokenAsync(InputArguments input) { + ThrowIfUnsafeRemote(input); + Uri remoteWithUserUri = input.GetRemoteUri(includeUser: true); string userName = input.UserName; - // We should not allow unencrypted communication and should inform the user - if (StringComparer.OrdinalIgnoreCase.Equals(remoteWithUserUri.Scheme, "http")) - { - throw new Trace2Exception(_context.Trace2, - "Unencrypted HTTP is not supported for Azure Repos. Ensure the repository remote URL is using HTTPS."); - } - Uri orgUri = UriHelpers.CreateOrganizationUri(remoteWithUserUri, out string orgName); _context.Trace.WriteLine($"Determining Microsoft Authentication authority for Azure DevOps organization '{orgName}'..."); diff --git a/src/shared/TestInfrastructure/Objects/TestSettings.cs b/src/shared/TestInfrastructure/Objects/TestSettings.cs index f14bf6cc9..3e67e39b0 100644 --- a/src/shared/TestInfrastructure/Objects/TestSettings.cs +++ b/src/shared/TestInfrastructure/Objects/TestSettings.cs @@ -53,6 +53,8 @@ public class TestSettings : ISettings public bool UseMsAuthDefaultAccount { get; set; } + public bool AllowUnsafeRemotes { get; set; } = false; + public Trace2Settings GetTrace2Settings() { return new Trace2Settings() @@ -189,6 +191,8 @@ ProxyConfiguration ISettings.GetProxyConfiguration() bool ISettings.UseSoftwareRendering => false; + bool ISettings.AllowUnsafeRemotes => AllowUnsafeRemotes; + #endregion #region IDisposable diff --git a/src/shared/TestInfrastructure/TestUtils.cs b/src/shared/TestInfrastructure/TestUtils.cs index c547856d7..000b8e75e 100644 --- a/src/shared/TestInfrastructure/TestUtils.cs +++ b/src/shared/TestInfrastructure/TestUtils.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics; using System.IO; +using System.Threading.Tasks; namespace GitCredentialManager.Tests { @@ -87,5 +89,41 @@ public static string GetUuid(int length = -1) return uuid.Substring(0, length); } + + public static async Task RunCommandAsync(string filePath, string arguments, string workingDirectory = null) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = filePath, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + } + }; + + process.Start(); + + string output = await process.StandardOutput.ReadToEndAsync(); + string error = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Command `{filePath} {arguments}` failed with exit code {process.ExitCode}." + + Environment.NewLine + + $"Output: {output}" + + Environment.NewLine + + $"Error: {error}"); + } + + return output; + } } } diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index bbd49a291..eae3631f0 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -1,12 +1,19 @@ - + + + + win-x64 + win-x86 + win-arm64 + + net472 false false - $(PlatformOutPath)Installer.Windows\bin\$(Configuration)\net472\win-x86 + $(PlatformOutPath)Installer.Windows\bin\$(Configuration)\net472\$(RuntimeIdentifier) 6.3.1 @@ -27,12 +34,20 @@ - "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" - "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" + "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" + "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" - + + + + + + diff --git a/src/windows/Installer.Windows/Setup.iss b/src/windows/Installer.Windows/Setup.iss index f03d16c9b..c15efe6d8 100644 --- a/src/windows/Installer.Windows/Setup.iss +++ b/src/windows/Installer.Windows/Setup.iss @@ -15,6 +15,10 @@ #error Installer target property 'InstallTarget' must be specifed #endif +#ifndef GcmRuntimeIdentifier + #error GCM Runtime Identifier 'GcmRuntimeIdentifier' must be specifed (e.g. win-x64) +#endif + #if InstallTarget == "user" #define GcmAppId "{{aa76d31d-432c-42ee-844c-bc0bc801cef3}}" #define GcmLongName "Git Credential Manager (User)" @@ -40,7 +44,6 @@ #define GcmRepoRoot "..\..\.." #define GcmAssets GcmRepoRoot + "\assets" #define GcmExe "git-credential-manager.exe" -#define GcmArch "x86" #ifnexist PayloadDir + "\" + GcmExe #error Payload files are missing @@ -67,9 +70,17 @@ AppUpdatesURL={#GcmUrl} AppContact={#GcmUrl} AppCopyright={#GcmCopyright} AppReadmeFile={#GcmReadme} +; Windows ARM64 supports installing and running x64 binaries, but not vice versa. +#if GcmRuntimeIdentifier=="win-x64" +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +#elif GcmRuntimeIdentifier=="win-arm64" +ArchitecturesAllowed=arm64 +ArchitecturesInstallIn64BitMode=arm64 +#endif VersionInfoVersion={#GcmVersion} LicenseFile={#GcmRepoRoot}\LICENSE -OutputBaseFilename={#GcmSetupExe}-win-{#GcmArch}-{#GcmVersionSimple} +OutputBaseFilename={#GcmSetupExe}-{#GcmRuntimeIdentifier}-{#GcmVersionSimple} DefaultDirName={autopf}\{#GcmShortName} Compression=lzma2 SolidCompression=yes diff --git a/src/windows/Installer.Windows/layout.ps1 b/src/windows/Installer.Windows/layout.ps1 index 070c9bf49..3fc43ab36 100644 --- a/src/windows/Installer.Windows/layout.ps1 +++ b/src/windows/Installer.Windows/layout.ps1 @@ -1,20 +1,43 @@ # Inputs -param ([Parameter(Mandatory)] $CONFIGURATION, [Parameter(Mandatory)] $OUTPUT, $SYMBOLOUTPUT) +param ([Parameter(Mandatory)] $Configuration, [Parameter(Mandatory)] $Output, $RuntimeIdentifier, $SymbolOutput) -Write-Output "Output: $OUTPUT" +Write-Output "Output: $Output" + +# Determine a runtime if one was not provided +if (-not $RuntimeIdentifier) { + $arch = $env:PROCESSOR_ARCHITECTURE + switch ($arch) { + "AMD64" { $RuntimeIdentifier = "win-x64" } + "x86" { $RuntimeIdentifier = "win-x86" } + "ARM64" { $RuntimeIdentifier = "win-arm64" } + default { + Write-Host "Unknown architecture: $arch" + exit 1 + } + } +} + +Write-Output "Building for runtime '$RuntimeIdentifier'" + +if ($RuntimeIdentifier -ne 'win-x86' -and $RuntimeIdentifier -ne 'win-x64' -and $RuntimeIdentifier -ne 'win-arm64') { + Write-Host "Unsupported RuntimeIdentifier: $RuntimeIdentifier" + exit 1 +} # Directories -$THISDIR = $pwd.path -$ROOT = (Get-Item $THISDIR).parent.parent.parent.FullName -$SRC = "$ROOT/src" -$GCM_SRC = "$SRC/shared/Git-Credential-Manager" +$THISDIR = $PSScriptRoot +$ROOT = (Get-Item $THISDIR).Parent.Parent.Parent.FullName +$SRC = "$ROOT\src" +$GCM_SRC = "$SRC\shared\Git-Credential-Manager" # Perform pre-execution checks -$PAYLOAD = "$OUTPUT" -if ($SYMBOLOUTPUT) +$PAYLOAD = "$Output" +if ($SymbolOutput) +{ + $SYMBOLS = "$SymbolOutput" +} +else { - $SYMBOLS = "$SYMBOLOUTPUT" -} else { $SYMBOLS = "$PAYLOAD.sym" } @@ -32,37 +55,65 @@ if (Test-Path -Path $SYMBOLS) } # Ensure payload and symbol directories exist -mkdir -p "$PAYLOAD","$SYMBOLS" +mkdir -p "$PAYLOAD","$SYMBOLS" | Out-Null # Publish core application executables Write-Output "Publishing core application..." dotnet publish "$GCM_SRC" ` --framework net472 ` - --configuration "$CONFIGURATION" ` - --runtime win-x86 ` + --configuration "$Configuration" ` + --runtime $RuntimeIdentifier ` --output "$PAYLOAD" # Delete libraries that are not needed for Windows but find their way # into the publish output. -Remove-Item -Path "$PAYLOAD/*.dylib" -Force - -# Delete extraneous files that get included for other architectures -# We only care about x86 as the core GCM executable is only targeting x86 -Remove-Item -Path "$PAYLOAD/arm/" -Recurse -Force -Remove-Item -Path "$PAYLOAD/arm64/" -Recurse -Force -Remove-Item -Path "$PAYLOAD/x64/" -Recurse -Force -Remove-Item -Path "$PAYLOAD/musl-x64/" -Recurse -Force -Remove-Item -Path "$PAYLOAD/runtimes/win-arm64/" -Recurse -Force -Remove-Item -Path "$PAYLOAD/runtimes/win-x64/" -Recurse -Force - -# The Avalonia and MSAL binaries in these directories are already included in -# the $PAYLOAD directory directly, so we can delete these extra copies. -Remove-Item -Path "$PAYLOAD/x86/libSkiaSharp.dll" -Recurse -Force -Remove-Item -Path "$PAYLOAD/x86/libHarfBuzzSharp.dll" -Recurse -Force -Remove-Item -Path "$PAYLOAD/runtimes/win-x86/native/msalruntime_x86.dll" -Recurse -Force +Remove-Item -Path "$PAYLOAD/*.dylib" -Force -ErrorAction Ignore + +# Delete extraneous files that get included for other runtimes +Remove-Item -Path "$PAYLOAD/musl-x64/" -Recurse -Force -ErrorAction Ignore + +switch ($RuntimeIdentifier) { + "win-x86" { + Remove-Item -Path "$PAYLOAD/arm/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/arm64/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x64/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-arm64/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-x64/" -Recurse -Force -ErrorAction Ignore + # The Avalonia and MSAL binaries are already included in the $PAYLOAD directory directly + Remove-Item -Path "$PAYLOAD/x86/libSkiaSharp.dll" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x86/libHarfBuzzSharp.dll" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-x86/native/msalruntime_x86.dll" -Force -ErrorAction Ignore + } + "win-x64" { + Remove-Item -Path "$PAYLOAD/arm/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/arm64/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x86/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-arm64/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-x86/" -Recurse -Force -ErrorAction Ignore + # The Avalonia and MSAL binaries are already included in the $PAYLOAD directory directly + Remove-Item -Path "$PAYLOAD/x64/libSkiaSharp.dll" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x64/libHarfBuzzSharp.dll" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x64/libSkiaSharp.so" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x64/libHarfBuzzSharp.so" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-x64/native/msalruntime.dll" -Force -ErrorAction Ignore + } + "win-arm64" { + Remove-Item -Path "$PAYLOAD/arm/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x86/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/x64/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-x86/" -Recurse -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-x64/" -Recurse -Force -ErrorAction Ignore + # The Avalonia and MSAL binaries are already included in the $PAYLOAD directory directly + Remove-Item -Path "$PAYLOAD/arm64/libSkiaSharp.dll" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/arm64/libHarfBuzzSharp.dll" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/arm64/libSkiaSharp.so" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/arm64/libHarfBuzzSharp.so" -Force -ErrorAction Ignore + Remove-Item -Path "$PAYLOAD/runtimes/win-arm64/native/msalruntime_arm64.dll" -Force -ErrorAction Ignore + } +} # Delete localized resource assemblies - we don't localize the core GCM assembly anyway -Get-ChildItem "$PAYLOAD" -Recurse -Include "*.resources.dll" | Remove-Item -Force +Get-ChildItem "$PAYLOAD" -Recurse -Include "*.resources.dll" | Remove-Item -Force -ErrorAction Ignore # Delete any empty directories Get-ChildItem "$PAYLOAD" -Recurse -Directory `