diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a4881143..c9d6a99c 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -6,6 +6,10 @@ on: branches: - master +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: run-tests: runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -37,3 +41,115 @@ jobs: run: | curl -fsSL https://cli.deepsource.com/install | BINDIR=./bin sh ./bin/deepsource report --analyzer test-coverage --key go --value-file ./coverage.out --use-oidc + + # Windows AMD64 coverage: release-equivalent (CGO_ENABLED=0) build, smoke + # tests, installer tests, and PE architecture verification. + windows-amd64: + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Go 1.x + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build release-equivalent binary + env: + CGO_ENABLED: '0' + GOOS: windows + GOARCH: amd64 + run: | + cd cmd/deepsource + go build -tags static_all -ldflags "-X 'main.version=0.0.0-ci' -X 'main.buildMode=dev'" -o deepsource-ci.exe . + + - name: Smoke test + run: | + & .\cmd\deepsource\deepsource-ci.exe --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\cmd\deepsource\deepsource-ci.exe --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify PE architecture + run: go run ./scripts/verify-pe-arch -arch amd64 cmd/deepsource/deepsource-ci.exe + + - name: Run installer tests + shell: pwsh + env: + DEEPSOURCE_EXPECTED_PLATFORM: windows_amd64 + run: ./scripts/tests/Run-Tests.ps1 + + - name: Run Windows-specific Go tests + run: | + go test ./scripts/verify-pe-arch + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test ./internal/update -run '^TestWindowsPlatformKey$' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # Windows ARM64 coverage on a native ARM64 GitHub-hosted runner + # (windows-11-arm). Host and Go toolchain architecture are asserted to be + # genuinely arm64 before building and smoke-testing a native binary. + windows-arm64: + runs-on: windows-11-arm + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Go 1.x + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Assert native ARM64 host and Go toolchain + shell: pwsh + run: | + $hostArch = $env:PROCESSOR_ARCHITECTURE + if ($hostArch -ne 'ARM64') { + throw "Expected a native ARM64 runner (PROCESSOR_ARCHITECTURE=ARM64), got '$hostArch'" + } + $goHostArch = (go env GOHOSTARCH).Trim() + if ($goHostArch -ne 'arm64') { + throw "Expected Go GOHOSTARCH=arm64, got '$goHostArch'" + } + Write-Host "Verified native ARM64 host: PROCESSOR_ARCHITECTURE=$hostArch, GOHOSTARCH=$goHostArch" + + - name: Build release-equivalent binary + env: + CGO_ENABLED: '0' + GOOS: windows + GOARCH: arm64 + run: | + cd cmd/deepsource + go build -tags static_all -ldflags "-X 'main.version=0.0.0-ci' -X 'main.buildMode=dev'" -o deepsource-ci.exe . + + - name: Smoke test + run: | + & .\cmd\deepsource\deepsource-ci.exe --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\cmd\deepsource\deepsource-ci.exe --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify PE architecture + run: go run ./scripts/verify-pe-arch -arch arm64 cmd/deepsource/deepsource-ci.exe + + - name: Run installer tests + shell: pwsh + env: + DEEPSOURCE_EXPECTED_PLATFORM: windows_arm64 + run: ./scripts/tests/Run-Tests.ps1 + + - name: Run Windows-specific Go tests + run: | + go test ./scripts/verify-pe-arch + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test ./internal/update -run '^TestWindowsPlatformKey$' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 598a0351..dd707883 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -177,38 +177,47 @@ jobs: build-windows: needs: resolve-env runs-on: blacksmith-4vcpu-ubuntu-2404 + strategy: + matrix: + goarch: [amd64, arm64] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - - name: Install cross-compiler - run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 - - name: Build env: - CGO_ENABLED: '1' + CGO_ENABLED: '0' GOOS: windows - GOARCH: amd64 - CC: x86_64-w64-mingw32-gcc - CXX: x86_64-w64-mingw32-g++ + GOARCH: ${{ matrix.goarch }} run: | BINARY_NAME="${{ needs.resolve-env.outputs.binary_name }}" cd cmd/deepsource && go build -tags static_all \ -ldflags "-X 'main.version=${{ needs.resolve-env.outputs.version }}' -X 'main.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)' -X 'main.SentryDSN=${{ secrets.SENTRY_DSN }}' -X 'main.buildMode=${{ needs.resolve-env.outputs.environment }}'" \ -o "${BINARY_NAME}.exe" . + - name: Verify PE architecture (raw binary) + run: | + BINARY_NAME="${{ needs.resolve-env.outputs.binary_name }}" + go run ./scripts/verify-pe-arch -arch ${{ matrix.goarch }} "cmd/deepsource/${BINARY_NAME}.exe" + - name: Package run: | BINARY_NAME="${{ needs.resolve-env.outputs.binary_name }}" - ARCHIVE="${BINARY_NAME}_${{ needs.resolve-env.outputs.version }}_windows_amd64.zip" + ARCHIVE="${BINARY_NAME}_${{ needs.resolve-env.outputs.version }}_windows_${{ matrix.goarch }}.zip" zip "$ARCHIVE" -j "cmd/deepsource/${BINARY_NAME}.exe" sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" + - name: Verify PE architecture (packaged archive) + run: | + BINARY_NAME="${{ needs.resolve-env.outputs.binary_name }}" + ARCHIVE="${BINARY_NAME}_${{ needs.resolve-env.outputs.version }}_windows_${{ matrix.goarch }}.zip" + go run ./scripts/verify-pe-arch -arch ${{ matrix.goarch }} "$ARCHIVE" + - uses: actions/upload-artifact@v4 with: - name: build-windows-amd64 + name: build-windows-${{ matrix.goarch }} path: | ${{ needs.resolve-env.outputs.binary_name }}_*.zip ${{ needs.resolve-env.outputs.binary_name }}_*.zip.sha256 @@ -258,6 +267,10 @@ jobs: "windows_amd64": { "archive": "${BINARY_NAME}_${VERSION}_windows_amd64.zip", "sha256": "$(get_sha "${BINARY_NAME}_${VERSION}_windows_amd64.zip")" + }, + "windows_arm64": { + "archive": "${BINARY_NAME}_${VERSION}_windows_arm64.zip", + "sha256": "$(get_sha "${BINARY_NAME}_${VERSION}_windows_arm64.zip")" } } } diff --git a/README.md b/README.md index ea989ae0..6b0b4cc1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ All commands support JSON output for scripting and automation. curl -sfL https://cli.deepsource.com/install | sh ``` +On Windows, use PowerShell: + +```powershell +irm https://cli.deepsource.com/install.ps1 | iex +``` + +The Windows installer selects the native AMD64 or ARM64 artifact advertised by the release +manifest. Native Windows ARM64 availability begins with the first release that includes the +`windows_arm64` artifact. + ## Documentation - [Installation](https://docs.deepsource.com/docs/cli#installation) diff --git a/internal/update/manifest_windows_test.go b/internal/update/manifest_windows_test.go new file mode 100644 index 00000000..aa20ecd6 --- /dev/null +++ b/internal/update/manifest_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package update + +import ( + "runtime" + "testing" +) + +func TestWindowsPlatformKey(t *testing.T) { + expected := map[string]string{ + "amd64": "windows_amd64", + "arm64": "windows_arm64", + } + + want, ok := expected[runtime.GOARCH] + if !ok { + t.Skipf("no Windows release artifact for GOARCH=%s", runtime.GOARCH) + } + if got := PlatformKey(); got != want { + t.Fatalf("PlatformKey() = %q, want %q", got, want) + } +} diff --git a/scripts/install.ps1.template b/scripts/install.ps1.template index 8f8a37c1..9bcb21a4 100644 --- a/scripts/install.ps1.template +++ b/scripts/install.ps1.template @@ -1,17 +1,84 @@ # DeepSource CLI installer for Windows -# Wrapped in a scriptblock so that 'return' does not close the host when run via `irm | iex` +# Wrapped in a scriptblock so helper functions do not leak into the host when run via `irm | iex` + +$InstallScript = { +param([switch]$SkipInstall) + +$BaseUrl = "__BASE_URL__" +$BinaryName = "__BINARY_NAME__" + +function Pass($msg) { Write-Host "✓ $msg" -ForegroundColor Green } +function Fail($msg) { Write-Host "✗ $msg" -ForegroundColor Red; return } +function Info($msg) { Write-Host "→ $msg" -ForegroundColor Cyan } + +# Get-DeepSourcePlatformKey detects the running architecture and maps it to a +# manifest platform key (e.g. "windows_arm64"). +# +# PROCESSOR_ARCHITEW6432 is checked first: it is set by Windows only when the +# current process is running under WOW64 emulation (e.g. an x86/x64 PowerShell +# host launched on an ARM64 machine), and always reflects the *native* host +# architecture in that case. PROCESSOR_ARCHITECTURE reflects the architecture +# of the current process itself, so it is used as the fallback for the common +# (non-emulated) case. +# +# The Architew6432/Architecture parameters default to the real environment +# variables but can be overridden by callers (tests) without mutating process +# environment state. +function Get-DeepSourcePlatformKey { + param( + [string]$Architew6432 = $env:PROCESSOR_ARCHITEW6432, + [string]$Architecture = $env:PROCESSOR_ARCHITECTURE + ) + + $rawArch = $Architew6432 + if ([string]::IsNullOrEmpty($rawArch)) { + $rawArch = $Architecture + } -& { - $ErrorActionPreference = "Stop" + if ([string]::IsNullOrEmpty($rawArch)) { + throw "Unable to determine processor architecture: neither PROCESSOR_ARCHITEW6432 nor PROCESSOR_ARCHITECTURE is set" + } - $BaseUrl = "__BASE_URL__" - $BinaryName = "__BINARY_NAME__" + switch ($rawArch.ToUpperInvariant()) { + "AMD64" { return "windows_amd64" } + "X86_64" { return "windows_amd64" } + "ARM64" { return "windows_arm64" } + "AARCH64" { return "windows_arm64" } + default { + throw "Unsupported architecture '$rawArch': DeepSource CLI does not provide a Windows build for this architecture" + } + } +} - function Pass($msg) { Write-Host "✓ $msg" -ForegroundColor Green } - function Fail($msg) { Write-Host "✗ $msg" -ForegroundColor Red; return } - function Info($msg) { Write-Host "→ $msg" -ForegroundColor Cyan } +# Install-DeepSourceCli runs the full install flow. It is a function (rather +# than inline script) so that 'return' exits only the install routine, not the +# host, when the script is invoked via 'irm | iex'. +# +# BaseUrl/BinaryName/InstallDir/Architew6432/Architecture parameters default to +# the real script-level values / environment variables and only need to be +# overridden by tests. +function Install-DeepSourceCli { + param( + [string]$BaseUrl = $BaseUrl, + [string]$BinaryName = $BinaryName, + [string]$InstallDir = (Join-Path $env:LOCALAPPDATA "DeepSource\bin"), + [string]$Architew6432 = $env:PROCESSOR_ARCHITEW6432, + [string]$Architecture = $env:PROCESSOR_ARCHITECTURE, + # PathScope defaults to User (real, persisted behavior). Tests may pass + # 'Process' so PATH assertions only touch the current process's + # in-memory environment and never persist to the real user profile. + [System.EnvironmentVariableTarget]$PathScope = [System.EnvironmentVariableTarget]::User + ) - $PlatformKey = "windows_amd64" + $ErrorActionPreference = "Stop" + + try { + $PlatformKey = Get-DeepSourcePlatformKey -Architew6432 $Architew6432 -Architecture $Architecture + } catch { + Fail "$_" + return + } + Pass "Platform: $PlatformKey" # Fetch manifest try { @@ -58,7 +125,6 @@ Pass "Checksum verified" # Extract binary - $InstallDir = Join-Path $env:LOCALAPPDATA "DeepSource\bin" if (-not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null } @@ -83,9 +149,9 @@ Pass "Installed to $InstallDir\$BinaryName.exe" # Add to user PATH if not already present - $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $UserPath = [Environment]::GetEnvironmentVariable("Path", $PathScope) if ($UserPath -notlike "*$InstallDir*") { - [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User") + [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", $PathScope) Pass "Added $InstallDir to user PATH" Info "Restart your terminal for PATH changes to take effect" } else { @@ -95,3 +161,10 @@ Write-Host "" Info "Run '$BinaryName' to get started" } + +if (-not $SkipInstall) { + Install-DeepSourceCli +} +} + +& $InstallScript diff --git a/scripts/tests/Install.Tests.ps1 b/scripts/tests/Install.Tests.ps1 new file mode 100644 index 00000000..a54a16fb --- /dev/null +++ b/scripts/tests/Install.Tests.ps1 @@ -0,0 +1,327 @@ +# Dependency-light installer tests for scripts/install.ps1.template. +# +# No Pester or other package dependencies are used: this is a plain +# PowerShell script that dot-sources the rendered installer (in test mode), +# exercises it directly, and reports PASS/FAIL per case. It works under both +# Windows PowerShell 5.1 (powershell.exe) and PowerShell 7+ (pwsh). +# +# Run with: +# powershell -NoProfile -File scripts\tests\Install.Tests.ps1 +# pwsh -NoProfile -File scripts\tests\Install.Tests.ps1 +# +# Exit code is non-zero if any test case fails. + +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "TestHttpServer.ps1") +. (Join-Path $PSScriptRoot "TestCommon.ps1") + +# Render the template once and dot-source its installer scriptblock without +# running an installation. This defines +# Get-DeepSourcePlatformKey / Install-DeepSourceCli (and $BaseUrl/$BinaryName) +# in this scope. +$renderedPath = New-RenderedInstallScript -BaseUrl "https://example.invalid" -BinaryName "deepsource-test" +try { + . $renderedPath +} finally { + Remove-Item -Path $renderedPath -Force -ErrorAction SilentlyContinue +} + +Invoke-TestCase "Template placeholders are substituted correctly" { + Assert-Equal "https://example.invalid" $BaseUrl "rendered BaseUrl" + Assert-Equal "deepsource-test" $BinaryName "rendered BinaryName" +} + +# --- Architecture detection (Get-DeepSourcePlatformKey) --------------------- + +Invoke-TestCase "Native ARM64 environment resolves to windows_arm64" { + $key = Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "ARM64" + Assert-Equal "windows_arm64" $key "platform key" +} + +Invoke-TestCase "Native AMD64 environment resolves to windows_amd64" { + $key = Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "AMD64" + Assert-Equal "windows_amd64" $key "platform key" +} + +Invoke-TestCase "Emulated x64 PowerShell on ARM64 host resolves to windows_arm64" { + # Windows sets PROCESSOR_ARCHITEW6432 to the *native* host architecture + # when the current process (PROCESSOR_ARCHITECTURE) is running under + # WOW64 emulation, e.g. x64 PowerShell launched on an ARM64 machine. + $key = Get-DeepSourcePlatformKey -Architew6432 "ARM64" -Architecture "AMD64" + Assert-Equal "windows_arm64" $key "platform key should prefer PROCESSOR_ARCHITEW6432" +} + +Invoke-TestCase "Architecture values normalize case-insensitively and accept aliases" { + Assert-Equal "windows_amd64" (Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "amd64") "lowercase amd64" + Assert-Equal "windows_arm64" (Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "Arm64") "mixed-case Arm64" + Assert-Equal "windows_amd64" (Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "x86_64") "x86_64 alias" + Assert-Equal "windows_arm64" (Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "aarch64") "aarch64 alias" + Assert-Equal "windows_arm64" (Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "AARCH64") "uppercase AARCH64 alias" +} + +Invoke-TestCase "Unsupported architecture throws a clear error" { + $threw = $false + try { + Get-DeepSourcePlatformKey -Architew6432 $null -Architecture "IA64" | Out-Null + } catch { + $threw = $true + Assert-Match "$_" "Unsupported architecture" "error message" + Assert-Match "$_" "IA64" "error message should include the offending value" + } + Assert-True $threw "expected Get-DeepSourcePlatformKey to throw for an unsupported architecture" +} + +Invoke-TestCase "Missing architecture environment info throws a clear error" { + $threw = $false + try { + Get-DeepSourcePlatformKey -Architew6432 $null -Architecture $null | Out-Null + } catch { + $threw = $true + Assert-Match "$_" "Unable to determine processor architecture" "error message" + } + Assert-True $threw "expected Get-DeepSourcePlatformKey to throw when no architecture info is available" +} + +if ($env:DEEPSOURCE_EXPECTED_PLATFORM) { + Invoke-TestCase "Real host environment resolves to the expected platform" { + Assert-Equal $env:DEEPSOURCE_EXPECTED_PLATFORM (Get-DeepSourcePlatformKey) "real host platform key" + } +} + +# --- Install-DeepSourceCli: failure paths that must not touch the network --- + +Invoke-TestCase "Unsupported architecture fails before any manifest/archive request" { + $installDir = New-TestInstallDir + $output = (Install-DeepSourceCli -BaseUrl "http://127.0.0.1:1" -BinaryName "deepsource-test" ` + -InstallDir $installDir -Architew6432 $null -Architecture "IA64" -PathScope Process 6>&1 | Out-String) + + Assert-Match $output "Unsupported architecture" "should report the architecture problem" + Assert-NoMatch $output "Failed to fetch manifest" "should fail before attempting a manifest fetch" + Assert-True (-not (Test-Path $installDir)) "install dir should never be created" +} + +# --- Install-DeepSourceCli: end-to-end against a local fixture server ------- + +function New-FixtureManifestAndServer { + # Sets up a local HTTP server serving a manifest with distinct ARM64 and + # AMD64 archives (so tests can prove the correct one was selected) and + # returns everything the calling test needs. + $binaryName = "deepsource-test" + $armContent = [System.Text.Encoding]::UTF8.GetBytes("ARM64-BINARY-CONTENT") + $amdContent = [System.Text.Encoding]::UTF8.GetBytes("AMD64-BINARY-CONTENT") + $armZip = New-TestZipArchive -EntryName "$binaryName.exe" -Content $armContent + $amdZip = New-TestZipArchive -EntryName "$binaryName.exe" -Content $amdContent + $armSha = Get-Sha256Hex -Bytes $armZip + $amdSha = Get-Sha256Hex -Bytes $amdZip + + $manifestObj = [ordered]@{ + version = "9.9.9" + buildTime = "2026-01-01T00:00:00Z" + platforms = [ordered]@{ + windows_arm64 = [ordered]@{ archive = "deepsource_9.9.9_windows_arm64.zip"; sha256 = $armSha } + windows_amd64 = [ordered]@{ archive = "deepsource_9.9.9_windows_amd64.zip"; sha256 = $amdSha } + } + } + $manifestJson = $manifestObj | ConvertTo-Json -Depth 5 + + $server = New-TestHttpServer + Add-TestRoute -Server $server -Path "/manifest.json" -Bytes ([System.Text.Encoding]::UTF8.GetBytes($manifestJson)) -ContentType "application/json" + Add-TestRoute -Server $server -Path "/build/deepsource_9.9.9_windows_arm64.zip" -Bytes $armZip + Add-TestRoute -Server $server -Path "/build/deepsource_9.9.9_windows_amd64.zip" -Bytes $amdZip + + return [pscustomobject]@{ + Server = $server + BinaryName = $binaryName + ArmContent = $armContent + AmdContent = $amdContent + ArmSha = $armSha + AmdSha = $amdSha + } +} + +Invoke-TestCase "End-to-end install selects the native ARM64 build" { + $fixture = New-FixtureManifestAndServer + try { + $installDir = New-TestInstallDir + Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -Architew6432 $null -Architecture "ARM64" -PathScope Process 6>$null | Out-Null + + $installedExe = Join-Path $installDir "$($fixture.BinaryName).exe" + Assert-True (Test-Path $installedExe) "expected $installedExe to exist" + $installedBytes = [System.IO.File]::ReadAllBytes($installedExe) + Assert-Equal ([System.Text.Encoding]::UTF8.GetString($fixture.ArmContent)) ` + ([System.Text.Encoding]::UTF8.GetString($installedBytes)) "installed binary should be the ARM64 build" + } finally { + Stop-TestHttpServer -Server $fixture.Server + } +} + +if ($env:DEEPSOURCE_EXPECTED_PLATFORM) { + Invoke-TestCase "End-to-end install uses the real host architecture defaults" { + $fixture = New-FixtureManifestAndServer + try { + $installDir = New-TestInstallDir + Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -PathScope Process 6>$null | Out-Null + + $expectedContent = if ($env:DEEPSOURCE_EXPECTED_PLATFORM -eq "windows_arm64") { + $fixture.ArmContent + } else { + $fixture.AmdContent + } + $installedExe = Join-Path $installDir "$($fixture.BinaryName).exe" + Assert-True (Test-Path $installedExe) "expected $installedExe to exist" + $installedBytes = [System.IO.File]::ReadAllBytes($installedExe) + Assert-Equal ([System.Text.Encoding]::UTF8.GetString($expectedContent)) ` + ([System.Text.Encoding]::UTF8.GetString($installedBytes)) "installed binary should match the real host architecture" + } finally { + Stop-TestHttpServer -Server $fixture.Server + } + } +} + +Invoke-TestCase "End-to-end install selects the native AMD64 build" { + $fixture = New-FixtureManifestAndServer + try { + $installDir = New-TestInstallDir + Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -Architew6432 $null -Architecture "AMD64" -PathScope Process 6>$null | Out-Null + + $installedExe = Join-Path $installDir "$($fixture.BinaryName).exe" + Assert-True (Test-Path $installedExe) "expected $installedExe to exist" + $installedBytes = [System.IO.File]::ReadAllBytes($installedExe) + Assert-Equal ([System.Text.Encoding]::UTF8.GetString($fixture.AmdContent)) ` + ([System.Text.Encoding]::UTF8.GetString($installedBytes)) "installed binary should be the AMD64 build" + } finally { + Stop-TestHttpServer -Server $fixture.Server + } +} + +Invoke-TestCase "Emulated x64 PowerShell on ARM64 host still installs the ARM64 build" { + $fixture = New-FixtureManifestAndServer + try { + $installDir = New-TestInstallDir + Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -Architew6432 "ARM64" -Architecture "AMD64" -PathScope Process 6>$null | Out-Null + + $installedExe = Join-Path $installDir "$($fixture.BinaryName).exe" + Assert-True (Test-Path $installedExe) "expected $installedExe to exist" + $installedBytes = [System.IO.File]::ReadAllBytes($installedExe) + Assert-Equal ([System.Text.Encoding]::UTF8.GetString($fixture.ArmContent)) ` + ([System.Text.Encoding]::UTF8.GetString($installedBytes)) "installed binary should be the native ARM64 build, not the emulated AMD64 one" + } finally { + Stop-TestHttpServer -Server $fixture.Server + } +} + +Invoke-TestCase "Missing manifest entry for the detected platform fails cleanly" { + # Manifest only advertises windows_amd64; requesting on ARM64 must fail + # with a clear message and must not create the install directory. + $binaryName = "deepsource-test" + $amdContent = [System.Text.Encoding]::UTF8.GetBytes("AMD64-ONLY-CONTENT") + $amdZip = New-TestZipArchive -EntryName "$binaryName.exe" -Content $amdContent + $amdSha = Get-Sha256Hex -Bytes $amdZip + + $manifestJson = [ordered]@{ + version = "9.9.9" + platforms = [ordered]@{ + windows_amd64 = [ordered]@{ archive = "deepsource_9.9.9_windows_amd64.zip"; sha256 = $amdSha } + } + } | ConvertTo-Json -Depth 5 + + $server = New-TestHttpServer + try { + Add-TestRoute -Server $server -Path "/manifest.json" -Bytes ([System.Text.Encoding]::UTF8.GetBytes($manifestJson)) -ContentType "application/json" + Add-TestRoute -Server $server -Path "/build/deepsource_9.9.9_windows_amd64.zip" -Bytes $amdZip + + $installDir = New-TestInstallDir + $output = (Install-DeepSourceCli -BaseUrl $server.BaseUrl -BinaryName $binaryName ` + -InstallDir $installDir -Architew6432 $null -Architecture "ARM64" -PathScope Process 6>&1 | Out-String) + + Assert-Match $output "No build available for windows_arm64" "should report the missing platform" + Assert-True (-not (Test-Path (Join-Path $installDir "$binaryName.exe"))) "binary should not be installed" + } finally { + Stop-TestHttpServer -Server $server + } +} + +Invoke-TestCase "Checksum mismatch fails cleanly and does not install the binary" { + $fixture = New-FixtureManifestAndServer + try { + # Overwrite the ARM64 route with mismatched content but keep the + # manifest's original (now-stale) sha256, forcing a checksum failure. + $corruptContent = [System.Text.Encoding]::UTF8.GetBytes("CORRUPTED-ARCHIVE-BYTES-DIFFERENT-LENGTH") + Add-TestRoute -Server $fixture.Server -Path "/build/deepsource_9.9.9_windows_arm64.zip" -Bytes $corruptContent + + $installDir = New-TestInstallDir + $output = (Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -Architew6432 $null -Architecture "ARM64" -PathScope Process 6>&1 | Out-String) + + Assert-Match $output "Checksum mismatch" "should report the checksum failure" + Assert-True (-not (Test-Path (Join-Path $installDir "$($fixture.BinaryName).exe"))) "binary should not be installed" + } finally { + Stop-TestHttpServer -Server $fixture.Server + } +} + +Invoke-TestCase "Installer keeps terminating error behavior when caller uses Continue" { + $fixture = New-FixtureManifestAndServer + $blockingFile = Join-Path ([System.IO.Path]::GetTempPath()) ("deepsource-blocking-{0}" -f ([guid]::NewGuid())) + [System.IO.File]::WriteAllText($blockingFile, "not a directory") + try { + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $threw = $false + try { + Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir (Join-Path $blockingFile "child") -Architew6432 $null -Architecture "ARM64" ` + -PathScope Process 6>$null | Out-Null + } catch { + $threw = $true + } finally { + $ErrorActionPreference = $previousPreference + } + Assert-True $threw "filesystem failures must terminate instead of reporting a false successful install" + } finally { + Stop-TestHttpServer -Server $fixture.Server + Remove-Item -Path $blockingFile -Force -ErrorAction SilentlyContinue + } +} + +Invoke-TestCase "PATH is updated only when the install dir is not already present" { + $fixture = New-FixtureManifestAndServer + try { + $installDir = New-TestInstallDir + + # Process-scoped PATH is isolated to this test process only and is + # never persisted, unlike the real (default) User scope. + $originalPath = [Environment]::GetEnvironmentVariable("Path", "Process") + try { + $output = (Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -Architew6432 $null -Architecture "ARM64" -PathScope Process 6>&1 | Out-String) + Assert-Match $output "Added " "should report PATH was updated" + + $updatedPath = [Environment]::GetEnvironmentVariable("Path", "Process") + Assert-True ($updatedPath -like "*$installDir*") "install dir should now be present in PATH" + + # Running again with the dir already present should not re-add it. + Install-DeepSourceCli -BaseUrl $fixture.Server.BaseUrl -BinaryName $fixture.BinaryName ` + -InstallDir $installDir -Architew6432 $null -Architecture "ARM64" -PathScope Process 6>$null | Out-Null + $pathAfterSecondInstall = [Environment]::GetEnvironmentVariable("Path", "Process") + Assert-Equal $updatedPath $pathAfterSecondInstall "second install should not duplicate the PATH entry" + } finally { + [Environment]::SetEnvironmentVariable("Path", $originalPath, "Process") + } + } finally { + Stop-TestHttpServer -Server $fixture.Server + } +} + +Remove-TestInstallDirs +Write-TestSummary + +if ($script:TestFailed -gt 0) { + exit 1 +} +exit 0 diff --git a/scripts/tests/Run-Tests.ps1 b/scripts/tests/Run-Tests.ps1 new file mode 100644 index 00000000..da58b6bf --- /dev/null +++ b/scripts/tests/Run-Tests.ps1 @@ -0,0 +1,35 @@ +# Runs the installer test suite under every available PowerShell host +# (Windows PowerShell 5.1 and PowerShell 7+/pwsh), so a regression that only +# shows up on one host is still caught. +# +# Usage: powershell -NoProfile -File scripts\tests\Run-Tests.ps1 + +$ErrorActionPreference = "Stop" +$testScript = Join-Path $PSScriptRoot "Install.Tests.ps1" + +$hosts = @() +if (Get-Command powershell.exe -ErrorAction SilentlyContinue) { + $hosts += "powershell.exe" +} +if (Get-Command pwsh -ErrorAction SilentlyContinue) { + $hosts += "pwsh" +} +if ($hosts.Count -eq 0) { + throw "Neither powershell.exe nor pwsh was found on PATH" +} + +$overallExit = 0 +foreach ($h in $hosts) { + Write-Host "" + Write-Host "=== Running installer tests under $h ===" -ForegroundColor Yellow + & $h -NoProfile -NoLogo -File $testScript + $exit = $LASTEXITCODE + if ($exit -ne 0) { + Write-Host "${h}: FAILED (exit $exit)" -ForegroundColor Red + $overallExit = 1 + } else { + Write-Host "${h}: OK" -ForegroundColor Green + } +} + +exit $overallExit diff --git a/scripts/tests/TestCommon.ps1 b/scripts/tests/TestCommon.ps1 new file mode 100644 index 00000000..2c57d1f2 --- /dev/null +++ b/scripts/tests/TestCommon.ps1 @@ -0,0 +1,162 @@ +# Shared helpers for the installer tests: rendering the .ps1.template with +# placeholder substitution (mirroring the real 'sed' render step used by +# .github/workflows/build-and-deploy.yml), building in-memory zip fixtures, +# hashing, and simple assertion/reporting utilities. + +$script:RepoScriptsDir = Split-Path -Parent $PSScriptRoot +$script:TemplatePath = Join-Path $script:RepoScriptsDir "install.ps1.template" + +# New-RenderedInstallScript renders install.ps1.template to a temp .ps1 file, +# substituting __BASE_URL__ / __BINARY_NAME__ exactly like the release +# workflow's 'sed' step. Returns the path to the rendered file. +function New-RenderedInstallScript { + param( + [string]$BaseUrl = "https://example.invalid", + [string]$BinaryName = "deepsource-test" + ) + + if (-not (Test-Path $script:TemplatePath)) { + throw "install.ps1.template not found at $script:TemplatePath" + } + + # The template contains non-ASCII glyphs (✓/✗/→); read/write it explicitly + # as UTF-8 so Windows PowerShell 5.1 (whose default Get-Content/Set-Content + # encoding is the system codepage, not UTF-8) doesn't mangle them. + $content = Get-Content -Path $script:TemplatePath -Raw -Encoding UTF8 + $content = $content.Replace("__BASE_URL__", $BaseUrl) + $content = $content.Replace("__BINARY_NAME__", $BinaryName) + $content = $content.Replace("& `$InstallScript", ". `$InstallScript -SkipInstall") + + $renderedPath = Join-Path ([System.IO.Path]::GetTempPath()) ("deepsource-install-test-{0}.ps1" -f ([guid]::NewGuid())) + Set-Content -Path $renderedPath -Value $content -NoNewline -Encoding UTF8 + return $renderedPath +} + +# New-TestZipArchive builds an in-memory zip archive (as a byte array) +# containing a single entry, mirroring the real windows_*.zip release +# archives (which contain just the .exe at the archive root). +function New-TestZipArchive { + param( + [Parameter(Mandatory)][string]$EntryName, + [Parameter(Mandatory)][byte[]]$Content + ) + + Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + + $ms = New-Object System.IO.MemoryStream + $archive = New-Object System.IO.Compression.ZipArchive($ms, [System.IO.Compression.ZipArchiveMode]::Create, $true) + try { + $entry = $archive.CreateEntry($EntryName) + $stream = $entry.Open() + try { + $stream.Write($Content, 0, $Content.Length) + } finally { + $stream.Close() + } + } finally { + $archive.Dispose() + } + + return $ms.ToArray() +} + +# Get-Sha256Hex computes the lowercase hex SHA256 of a byte array, matching +# the format written by Get-FileHash in the installer. +function Get-Sha256Hex { + param([Parameter(Mandatory)][byte[]]$Bytes) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($Bytes) + return -join ($hash | ForEach-Object { $_.ToString("x2") }) + } finally { + $sha.Dispose() + } +} + +# New-TestInstallDir returns a fresh, unique temp directory path (not yet +# created) suitable for use as -InstallDir in tests, keeping every test case +# isolated from the real %LOCALAPPDATA%\DeepSource\bin and from each other. +# Every path returned is tracked so Remove-TestInstallDirs can clean up after +# the run (successful installs intentionally leave files on disk). +$script:CreatedTestInstallDirs = New-Object System.Collections.Generic.List[string] + +function New-TestInstallDir { + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("deepsource-test-install-{0}" -f ([guid]::NewGuid())) + $script:CreatedTestInstallDirs.Add($dir) + return $dir +} + +function Remove-TestInstallDirs { + foreach ($dir in $script:CreatedTestInstallDirs) { + if (Test-Path $dir) { + Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +# --- Minimal test reporting ------------------------------------------------- + +$script:TestPassed = 0 +$script:TestFailed = 0 +$script:TestFailureDetails = New-Object System.Collections.Generic.List[string] + +function Assert-Equal { + param($Expected, $Actual, [string]$Because = "values should match") + if ($Expected -ne $Actual) { + throw "$Because (expected '$Expected', got '$Actual')" + } +} + +function Assert-True { + param([bool]$Condition, [string]$Because = "condition should be true") + if (-not $Condition) { + throw $Because + } +} + +function Assert-Match { + param([string]$Text, [string]$Pattern, [string]$Because = "text should match pattern") + if ($Text -notmatch $Pattern) { + throw "$Because (pattern '$Pattern' not found in: $Text)" + } +} + +function Assert-NoMatch { + param([string]$Text, [string]$Pattern, [string]$Because = "text should not match pattern") + if ($Text -match $Pattern) { + throw "$Because (pattern '$Pattern' unexpectedly found in: $Text)" + } +} + +function Invoke-TestCase { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Body + ) + + try { + & $Body + $script:TestPassed++ + Write-Host "[PASS] $Name" -ForegroundColor Green + } catch { + $script:TestFailed++ + $script:TestFailureDetails.Add("$Name : $_") + Write-Host "[FAIL] $Name" -ForegroundColor Red + Write-Host " $_" -ForegroundColor Red + } +} + +function Write-TestSummary { + Write-Host "" + Write-Host "----------------------------------------" + Write-Host "Passed: $script:TestPassed Failed: $script:TestFailed" + if ($script:TestFailed -gt 0) { + Write-Host "Failures:" -ForegroundColor Red + foreach ($f in $script:TestFailureDetails) { + Write-Host " - $f" -ForegroundColor Red + } + } + Write-Host "----------------------------------------" +} diff --git a/scripts/tests/TestHttpServer.ps1 b/scripts/tests/TestHttpServer.ps1 new file mode 100644 index 00000000..3ee5d675 --- /dev/null +++ b/scripts/tests/TestHttpServer.ps1 @@ -0,0 +1,138 @@ +# Minimal, dependency-free local HTTP server used by the installer tests. +# +# Serves fixed byte-array responses for a small set of routes from a +# background PowerShell instance backed by System.Net.HttpListener. No +# external modules (Pester, etc.) are required; only .NET types already +# shipped with Windows PowerShell 5.1 and PowerShell 7 (pwsh). + +function New-TestHttpServer { + param( + [int]$Port = 0 + ) + + # HttpListener requires exclusive access to a port; retry a few times + # with a fresh random port if the chosen one is unavailable. + $attempts = 0 + while ($true) { + $attempts++ + $tryPort = if ($Port -ne 0) { $Port } else { Get-Random -Minimum 20000 -Maximum 60000 } + + $routes = [hashtable]::Synchronized(@{}) + $requestLog = [System.Collections.ArrayList]::Synchronized(([System.Collections.ArrayList]::new())) + + $ps = [powershell]::Create() + $null = $ps.AddScript({ + param($port, $routes, $requestLog) + + $listener = New-Object System.Net.HttpListener + $listener.Prefixes.Add("http://127.0.0.1:$port/") + $listener.Start() + + while ($listener.IsListening) { + try { + $ctx = $listener.GetContext() + } catch { + break + } + + $path = $ctx.Request.Url.AbsolutePath + [void]$requestLog.Add($path) + + if ($routes.ContainsKey($path)) { + $route = $routes[$path] + $ctx.Response.StatusCode = $route.StatusCode + $ctx.Response.ContentType = $route.ContentType + $bytes = $route.Bytes + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } else { + $ctx.Response.StatusCode = 404 + } + $ctx.Response.OutputStream.Close() + + if ($routes.ContainsKey("__stop__")) { + $listener.Stop() + break + } + } + }).AddArgument($tryPort).AddArgument($routes).AddArgument($requestLog) + + $asyncResult = $ps.BeginInvoke() + + # Actively poll for the listener to accept TCP connections instead of + # a fixed sleep: background PowerShell instance startup time varies + # (more so under some hosts/loads), and a fixed short sleep was + # observed to race the listener's Start() call intermittently. + $ready = $false + $deadline = (Get-Date).AddSeconds(5) + while ((Get-Date) -lt $deadline) { + if ($ps.InvocationStateInfo.State -eq [System.Management.Automation.PSInvocationState]::Failed) { + break + } + try { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect("127.0.0.1", $tryPort) + $client.Close() + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 50 + } + } + + if (-not $ready) { + $ps.Stop() + $ps.Dispose() + if ($attempts -ge 10) { + throw "Failed to start test HTTP server after $attempts attempts" + } + continue + } + + return [pscustomobject]@{ + Port = $tryPort + BaseUrl = "http://127.0.0.1:$tryPort" + Routes = $routes + RequestLog = $requestLog + PowerShell = $ps + AsyncResult = $asyncResult + } + } +} + +function Add-TestRoute { + param( + [Parameter(Mandatory)] $Server, + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][byte[]]$Bytes, + [int]$StatusCode = 200, + [string]$ContentType = "application/octet-stream" + ) + + $Server.Routes[$Path] = [pscustomobject]@{ + Bytes = $Bytes + StatusCode = $StatusCode + ContentType = $ContentType + } +} + +function Get-TestRequestCount { + param([Parameter(Mandatory)] $Server) + return $Server.RequestLog.Count +} + +function Stop-TestHttpServer { + param([Parameter(Mandatory)] $Server) + + $Server.Routes["__stop__"] = $true + # Nudge the blocking GetContext() call so the loop notices the stop flag. + try { + Invoke-WebRequest -Uri "$($Server.BaseUrl)/__shutdown__" -UseBasicParsing -TimeoutSec 2 | Out-Null + } catch { + # ignored: the listener may already be stopping + } + Start-Sleep -Milliseconds 150 + + try { $Server.PowerShell.Stop() } catch {} + try { $Server.PowerShell.Dispose() } catch {} +} diff --git a/scripts/verify-pe-arch/main.go b/scripts/verify-pe-arch/main.go new file mode 100644 index 00000000..b195d268 --- /dev/null +++ b/scripts/verify-pe-arch/main.go @@ -0,0 +1,133 @@ +// Command verify-pe-arch checks that a Windows PE binary (or the single .exe +// entry inside a .zip release archive) reports the expected machine +// architecture in its PE header. It is used by both CI.yml and +// build-and-deploy.yml to catch cases where a "windows/arm64" build silently +// produces an amd64 (or otherwise wrong) binary. +// +// It only depends on the Go standard library (archive/zip, debug/pe) so it +// runs identically on Linux (release cross-compile job) and Windows (CI +// native jobs) via `go run ./scripts/verify-pe-arch`. +package main + +import ( + "archive/zip" + "bytes" + "debug/pe" + "flag" + "fmt" + "io" + "os" + "strings" +) + +// archForMachine maps a PE FileHeader.Machine value to the GOARCH-style name +// used in this repository's build matrices. +func archForMachine(machine uint16) string { + switch machine { + case pe.IMAGE_FILE_MACHINE_AMD64: + return "amd64" + case pe.IMAGE_FILE_MACHINE_ARM64: + return "arm64" + case pe.IMAGE_FILE_MACHINE_I386: + return "386" + case pe.IMAGE_FILE_MACHINE_ARMNT: + return "arm" + default: + return fmt.Sprintf("unknown(0x%04x)", machine) + } +} + +// machineFromPEFile parses a raw PE file on disk and returns its machine type. +func machineFromPEFile(path string) (uint16, error) { + f, err := pe.Open(path) + if err != nil { + return 0, fmt.Errorf("parsing PE file %q: %w", path, err) + } + defer f.Close() + return f.FileHeader.Machine, nil +} + +// machineFromZip finds the single .exe entry inside a .zip release archive +// and returns its machine type, without extracting anything to disk. +func machineFromZip(path string) (uint16, error) { + zr, err := zip.OpenReader(path) + if err != nil { + return 0, fmt.Errorf("opening zip %q: %w", path, err) + } + defer zr.Close() + + var exeFile *zip.File + for _, f := range zr.File { + if strings.HasSuffix(strings.ToLower(f.Name), ".exe") { + if exeFile != nil { + return 0, fmt.Errorf("multiple .exe entries found in %q", path) + } + exeFile = f + } + } + if exeFile == nil { + return 0, fmt.Errorf("no .exe entry found in %q", path) + } + + rc, err := exeFile.Open() + if err != nil { + return 0, fmt.Errorf("opening zip entry %q: %w", exeFile.Name, err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return 0, fmt.Errorf("reading zip entry %q: %w", exeFile.Name, err) + } + + pf, err := pe.NewFile(bytes.NewReader(data)) + if err != nil { + return 0, fmt.Errorf("parsing PE data from %q: %w", exeFile.Name, err) + } + defer pf.Close() + return pf.FileHeader.Machine, nil +} + +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("verify-pe-arch", flag.ContinueOnError) + fs.SetOutput(stderr) + arch := fs.String("arch", "", "expected architecture (amd64 or arm64)") + fs.Usage = func() { + fmt.Fprintf(stderr, "usage: verify-pe-arch -arch \n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *arch == "" || fs.NArg() != 1 { + fs.Usage() + return 2 + } + path := fs.Arg(0) + + var ( + machine uint16 + err error + ) + if strings.HasSuffix(strings.ToLower(path), ".zip") { + machine, err = machineFromZip(path) + } else { + machine, err = machineFromPEFile(path) + } + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + got := archForMachine(machine) + fmt.Fprintf(stdout, "%s: PE machine=0x%04x (%s)\n", path, machine, got) + if got != *arch { + fmt.Fprintf(stderr, "architecture mismatch: expected %s, got %s\n", *arch, got) + return 1 + } + return 0 +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/scripts/verify-pe-arch/main_test.go b/scripts/verify-pe-arch/main_test.go new file mode 100644 index 00000000..89b45961 --- /dev/null +++ b/scripts/verify-pe-arch/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "archive/zip" + "bytes" + "debug/pe" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeMinimalPE(t *testing.T, machine uint16) []byte { + t.Helper() + + data := make([]byte, 0x80) + data[0] = 'M' + data[1] = 'Z' + binary.LittleEndian.PutUint32(data[0x3c:], 0x40) + copy(data[0x40:], []byte{'P', 'E', 0, 0}) + binary.LittleEndian.PutUint16(data[0x44:], machine) + return data +} + +func TestRunRawPE(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.exe") + if err := os.WriteFile(path, writeMinimalPE(t, pe.IMAGE_FILE_MACHINE_ARM64), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := run([]string{"-arch", "arm64", path}, &stdout, &stderr); code != 0 { + t.Fatalf("run returned %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "0xaa64 (arm64)") { + t.Fatalf("unexpected output: %s", stdout.String()) + } +} + +func TestRunZipDetectsMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.zip") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + entry, err := archive.Create("deepsource.exe") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(writeMinimalPE(t, pe.IMAGE_FILE_MACHINE_AMD64)); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := run([]string{"-arch", "arm64", path}, &stdout, &stderr); code != 1 { + t.Fatalf("run returned %d, want 1", code) + } + if !strings.Contains(stderr.String(), "expected arm64, got amd64") { + t.Fatalf("unexpected error: %s", stderr.String()) + } +} + +func TestRunZipRejectsMultipleExecutables(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.zip") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + for _, name := range []string{"deepsource.exe", "helper.exe"} { + entry, err := archive.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(writeMinimalPE(t, pe.IMAGE_FILE_MACHINE_ARM64)); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := run([]string{"-arch", "arm64", path}, &stdout, &stderr); code != 1 { + t.Fatalf("run returned %d, want 1", code) + } + if !strings.Contains(stderr.String(), "multiple .exe entries") { + t.Fatalf("unexpected error: %s", stderr.String()) + } +}