diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c80f9b587..2de98099b 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -109,6 +109,14 @@ jobs: run: | uv run pwsh scripts/windows/build-binary.ps1 + - name: Test install.ps1 end-to-end (Windows) + if: matrix.platform == 'windows' + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/windows/test-install-script.ps1 + - name: Upload binary as workflow artifact uses: actions/upload-artifact@v4 with: diff --git a/docs/src/content/docs/getting-started/installation.md b/docs/src/content/docs/getting-started/installation.md index 71295ef75..df9df8989 100644 --- a/docs/src/content/docs/getting-started/installation.md +++ b/docs/src/content/docs/getting-started/installation.md @@ -252,6 +252,30 @@ $env:APM_DEBUG = "1" apm install ``` +### `Access is denied` running apm.exe on Windows (AppLocker / App Control for Business) + +If the installer (or `apm self-update`) fails at the `Testing binary...` step with `Access is denied` / HRESULT `0x80070005`, an enterprise application control policy ([AppLocker](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/applocker-overview) or [App Control for Business / WDAC](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/)) is blocking execution of `apm.exe` from a user-writable path. + +The installer stages the binary under `%LOCALAPPDATA%\Programs\apm\releases\` **before** invoking it, so a single allow-list rule for that path is enough. + +Ask your endpoint admin to add one of: + +- **Path rule:** `%LOCALAPPDATA%\Programs\apm\*` +- **Publisher / hash rule** for the released `apm.exe` + +If you cannot change policy, set `APM_TEMP_DIR` to a directory your policy allows and retry: + +```powershell +$env:APM_TEMP_DIR = "$env:LOCALAPPDATA\Programs\apm\tmp" +irm https://aka.ms/apm-windows | iex +``` + +As a last resort, install via pip (runs from your Python user site): + +```powershell +pip install --user apm-cli +``` + ## Next steps See the [Quick Start](../quick-start/) to set up your first project. \ No newline at end of file diff --git a/install.ps1 b/install.ps1 index b20bffc62..5d6969723 100644 --- a/install.ps1 +++ b/install.ps1 @@ -243,6 +243,72 @@ function Write-ManualInstallHelp { Write-Host "Need help? Create an issue at: $GithubUrl/$ApmRepo/issues" } +function Get-Sha256Hex { + # Stream-based SHA256 that works even when Get-FileHash is unavailable + # (hardened hosts, $PSModuleAutoLoadingPreference='None', restricted sessions). + # System.Security.Cryptography is a core .NET type allowed in ConstrainedLanguage. + param([string]$Path) + $cmd = Get-Command Get-FileHash -ErrorAction SilentlyContinue + if (-not $cmd) { + try { + Import-Module Microsoft.PowerShell.Utility -ErrorAction Stop + $cmd = Get-Command Get-FileHash -ErrorAction SilentlyContinue + } catch { + } + } + if ($cmd) { + return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLower() + } + $stream = $null + $hasher = $null + try { + $stream = [System.IO.File]::OpenRead($Path) + $hasher = [System.Security.Cryptography.SHA256]::Create() + $bytes = $hasher.ComputeHash($stream) + $sb = New-Object System.Text.StringBuilder + foreach ($b in $bytes) { [void]$sb.Append($b.ToString("x2")) } + return $sb.ToString() + } finally { + if ($hasher) { $hasher.Dispose() } + if ($stream) { $stream.Dispose() } + } +} + +function Test-AccessDeniedError { + # AppLocker / WDAC / App Control for Business denies CreateProcess on EXEs + # under user-writable paths (e.g. %TEMP%, %LOCALAPPDATA%\Temp) with HRESULT + # 0x80070005 (E_ACCESSDENIED), surfaced by PowerShell as "Access is denied". + param([string]$Text) + if (-not $Text) { return $false } + return ($Text -match 'Access is denied' -or $Text -match '0x80070005') +} + +function Write-AppControlGuidance { + param( + [string]$Path, + [string]$TargetInstallDir + ) + Write-Host "" + Write-ErrorText "The OS denied execution of $Path." + Write-Host "This is the standard signature of an enterprise application control policy" + Write-Host "(AppLocker or App Control for Business / WDAC) denying an unsigned binary" + Write-Host "from a user-writable path." + Write-Host "" + Write-Info "Options to unblock:" + if ($TargetInstallDir) { + Write-Host " 1. Ask your endpoint admin to allow-list the final install path" + Write-Host " ($TargetInstallDir) via an AppLocker/WDAC Path or Publisher rule." + } else { + Write-Host " 1. Ask your endpoint admin to allow-list apm.exe via an" + Write-Host " AppLocker/WDAC Path or Publisher rule." + } + Write-Host " 2. Set APM_TEMP_DIR to a directory your policy permits, then retry:" + Write-Host " `$env:APM_TEMP_DIR = `"`$env:LOCALAPPDATA\Programs\apm\tmp`"" + Write-Host " 3. Install via pip into your user site:" + Write-Host " pip install --user apm-cli" + Write-Host "" +} + # --------------------------------------------------------------------------- # Banner # --------------------------------------------------------------------------- @@ -487,7 +553,7 @@ try { if ($fetched -and (Test-Path $sha256Path)) { try { $expectedHash = (Get-Content $sha256Path -Raw).Trim().Split(" ")[0] - $actualHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLower() + $actualHash = Get-Sha256Hex -Path $zipPath if ($actualHash -ne $expectedHash) { Write-ErrorText "Checksum verification FAILED." Write-Host " Expected: $expectedHash" @@ -515,49 +581,115 @@ try { } # ------------------------------------------------------------------ - # Extract + # Extract + stage + binary test + promote + # + # Order matters: AppLocker / App Control for Business commonly block + # executable launch from %TEMP%. We move the extracted bundle to the + # final per-user install root ($releasesDir, default + # %LOCALAPPDATA%\Programs\apm\releases\) BEFORE invoking + # apm.exe --version, so the binary test runs from the allow-listed + # path that the shim will keep pointing at. Until promotion succeeds + # we stage to a sibling `.new-` directory so a failed install + # never destroys the currently working release. See issue #1389. # ------------------------------------------------------------------ Write-Info "Extracting package..." Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force $packageDir = Join-Path $tempDir "apm-windows-x86_64" - $exePath = Join-Path $packageDir "apm.exe" - if (-not (Test-Path $exePath)) { - Write-ErrorText "Extracted package is missing apm.exe." + if (-not (Test-Path $packageDir)) { + Write-ErrorText "Extracted package is missing the apm-windows-x86_64 directory." Write-Info "Attempting automatic fallback to pip..." if (Install-ViaPip) { exit 0 } Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo exit 1 } - # ------------------------------------------------------------------ - # Binary test - # ------------------------------------------------------------------ + $stagingDir = "$releaseDir.new-" + [System.Guid]::NewGuid().ToString("N") + if (Test-Path $stagingDir) { + Remove-Item -Recurse -Force $stagingDir + } + try { + Move-Item -Path $packageDir -Destination $stagingDir -Force + } catch { + $stageError = "$_" + Write-ErrorText "Failed to stage release at ${stagingDir}: $stageError" + if (Test-AccessDeniedError -Text $stageError) { + Write-AppControlGuidance -Path $stagingDir -TargetInstallDir $releaseDir + } + Write-Info "Attempting automatic fallback to pip..." + if (Install-ViaPip) { exit 0 } + Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo + exit 1 + } + + $stagedExe = Join-Path $stagingDir "apm.exe" + if (-not (Test-Path $stagedExe)) { + Write-ErrorText "Staged package is missing apm.exe." + Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue + Write-Info "Attempting automatic fallback to pip..." + if (Install-ViaPip) { exit 0 } + Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo + exit 1 + } Write-Info "Testing binary..." + $testFailure = $null try { - $testOutput = & $exePath --version 2>&1 - if ($LASTEXITCODE -ne 0) { throw "exit code $LASTEXITCODE" } + $testOutput = & $stagedExe --version 2>&1 + if ($LASTEXITCODE -ne 0) { throw "exit code $LASTEXITCODE - $testOutput" } Write-Success "Binary test successful: $testOutput" } catch { - Write-ErrorText "Downloaded binary failed to run: $_" - Write-Host "" + $testFailure = "$_" + } + + if ($testFailure) { + $denied = Test-AccessDeniedError -Text $testFailure + Write-ErrorText "Downloaded binary failed to run: $testFailure" + if ($denied) { + Write-AppControlGuidance -Path $stagedExe -TargetInstallDir $releaseDir + } + Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue Write-Info "Attempting automatic fallback to pip..." if (Install-ViaPip) { exit 0 } Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo exit 1 } - # ------------------------------------------------------------------ - # Install - # ------------------------------------------------------------------ - + # Promote: rename the existing release aside, then rename the staged + # tree into place. Win32 has no truly atomic directory replacement, so + # there is still a small gap where neither path exists; doing it this + # way minimizes that gap and lets us roll back if the second rename + # fails. Concurrent apm invocations during that window will fail and + # need a retry — acceptable for an install/self-update operation. + $backupDir = $null if (Test-Path $releaseDir) { - Remove-Item -Recurse -Force $releaseDir + $backupDir = "$releaseDir.old-" + [System.Guid]::NewGuid().ToString("N") + try { + Move-Item -Path $releaseDir -Destination $backupDir -Force + } catch { + Write-ErrorText "Failed to move existing release aside: $_" + Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue + Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo + exit 1 + } + } + + try { + Move-Item -Path $stagingDir -Destination $releaseDir -Force + } catch { + Write-ErrorText "Failed to promote staged release: $_" + if ($backupDir -and (Test-Path $backupDir)) { + Move-Item -Path $backupDir -Destination $releaseDir -Force -ErrorAction SilentlyContinue + } + Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue + Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo + exit 1 } - Move-Item -Path $packageDir -Destination $releaseDir + if ($backupDir -and (Test-Path $backupDir)) { + Remove-Item -Recurse -Force $backupDir -ErrorAction SilentlyContinue + } $shimPath = Join-Path $binDir "apm.cmd" $shimContent = "@echo off`r`n`"$releaseDir\apm.exe`" %*`r`n" diff --git a/scripts/windows/test-install-script.ps1 b/scripts/windows/test-install-script.ps1 new file mode 100644 index 000000000..302365386 --- /dev/null +++ b/scripts/windows/test-install-script.ps1 @@ -0,0 +1,409 @@ +# Windows-only end-to-end test for install.ps1. +# +# Covers the fixes for issue microsoft/apm#1389: +# 1. SHA256 verification works on hardened hosts where Get-FileHash is not +# autoloaded (.NET stream fallback via System.Security.Cryptography). +# 2. The binary smoke test runs from the final install root (under +# %LOCALAPPDATA%\Programs\apm\releases\...), NOT from %TEMP%, so it +# survives AppLocker / App Control for Business policies that block +# executable launch from user-writable temp paths. +# 3. The shim written to APM_INSTALL_DIR points at the promoted release +# directory and the temp staging area is cleaned up. +# 4. Upgrading over an existing install exercises the "move releaseDir +# aside -> promote staging -> delete backup" path with no leftovers. +# 5. The real `apm self-update` command launches install.ps1 successfully +# end-to-end (download + dispatch + new version reported). +# +# Designed to run on the windows-latest GitHub Actions runner. Performs a +# real install of a pinned APM release into an isolated test prefix and +# leaves the developer's existing apm install untouched. + +param( + [string]$PinnedVersion = "v0.14.0", + [string]$OlderVersion = "v0.13.0" +) + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir "..\..") +$InstallScript = Join-Path $RepoRoot "install.ps1" + +function Write-Info { param([string]$M) Write-Host "[INFO] $M" -ForegroundColor Blue } +function Write-Success { param([string]$M) Write-Host "[OK] $M" -ForegroundColor Green } +function Write-Step { param([string]$M) Write-Host "[STEP] $M" -ForegroundColor Cyan } +function Write-Fail { param([string]$M) Write-Host "[FAIL] $M" -ForegroundColor Red } + +$Script:Failures = @() +function Assert-True { + param([bool]$Condition, [string]$Message) + if ($Condition) { + Write-Success $Message + } else { + Write-Fail $Message + $Script:Failures += $Message + } +} + +# --------------------------------------------------------------------------- +# Test 1: Get-Sha256Hex function falls back to .NET when Get-FileHash is gone. +# --------------------------------------------------------------------------- + +function Test-Sha256Fallback { + Write-Step "Test 1: Get-Sha256Hex .NET fallback works without Get-FileHash" + + if (-not (Test-Path $InstallScript)) { + Write-Fail "install.ps1 not found at $InstallScript" + $Script:Failures += "install.ps1 missing" + return + } + + $content = Get-Content $InstallScript -Raw + $pattern = '(?s)function Get-Sha256Hex\s*\{.*?\n\}' + $match = [regex]::Match($content, $pattern) + if (-not $match.Success) { + Write-Fail "Could not extract Get-Sha256Hex function from install.ps1" + $Script:Failures += "Get-Sha256Hex extraction" + return + } + + $tempFile = [System.IO.Path]::GetTempFileName() + try { + Set-Content -Path $tempFile -Value "the quick brown fox" -NoNewline -Encoding ASCII + $expected = (Get-FileHash -Path $tempFile -Algorithm SHA256).Hash.ToLower() + + # Run the extracted function in an isolated child pwsh with the + # PSModulePath cleared and Microsoft.PowerShell.Utility removed, + # which simulates a hardened host where Get-Command Get-FileHash + # returns nothing and the fallback must take over. + $childScript = @" +`$ErrorActionPreference = 'Stop' +`$env:PSModulePath = '' +Remove-Module Microsoft.PowerShell.Utility -Force -ErrorAction SilentlyContinue +$($match.Value) +Write-Output (Get-Sha256Hex -Path '$tempFile') +"@ + $childScriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" + Set-Content -Path $childScriptPath -Value $childScript -Encoding UTF8 + try { + $actual = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $childScriptPath 2>&1 + $actualStr = ($actual | Out-String).Trim().ToLower() + Assert-True ($actualStr -eq $expected) "SHA256 fallback returns expected hash (expected $expected, got $actualStr)" + } finally { + Remove-Item $childScriptPath -ErrorAction SilentlyContinue + } + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# Test 2: Structural assertion that binary test happens from the final +# release tree, not from the system temp dir. We assert this by reading +# install.ps1 and confirming that the move-then-test ordering is in place. +# --------------------------------------------------------------------------- + +function Test-MoveThenTestOrdering { + Write-Step "Test 2: install.ps1 moves bundle out of temp before running binary test" + + # Parse the script via the PowerShell AST so the assertion is robust to + # whitespace, line wrapping, added parameters, or quoting changes. + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($InstallScript, [ref]$tokens, [ref]$errors) + Assert-True ((-not $errors) -or ($errors.Count -eq 0)) "install.ps1 parses cleanly" + if ($errors -and $errors.Count -gt 0) { return } + + # Find every Move-Item invocation that mentions $packageDir and $stagingDir + # (either as -Path/-Destination args or as positional values) — that's our + # staging move. + $moveCalls = $ast.FindAll({ + param($n) + if ($n -isnot [System.Management.Automation.Language.CommandAst]) { return $false } + $cmdName = $n.GetCommandName() + if ($cmdName -ne 'Move-Item') { return $false } + $text = $n.Extent.Text + return ($text -match '\$packageDir' -and $text -match '\$stagingDir') + }, $true) + + # Find the smoke test invocation: any call expression that invokes + # $stagedExe with --version. + $smokeCalls = $ast.FindAll({ + param($n) + if ($n -isnot [System.Management.Automation.Language.CommandAst]) { return $false } + $text = $n.Extent.Text + return ($text -match '\$stagedExe' -and $text -match '--version') + }, $true) + + Assert-True ($moveCalls.Count -ge 1) "AST: found Move-Item staging call referencing \$packageDir + \$stagingDir" + Assert-True ($smokeCalls.Count -ge 1) "AST: found smoke-test invocation of \$stagedExe --version" + + if ($moveCalls.Count -ge 1 -and $smokeCalls.Count -ge 1) { + $firstStageOffset = ($moveCalls | ForEach-Object { $_.Extent.StartOffset } | Sort-Object | Select-Object -First 1) + $firstSmokeOffset = ($smokeCalls | ForEach-Object { $_.Extent.StartOffset } | Sort-Object | Select-Object -First 1) + Assert-True ($firstStageOffset -lt $firstSmokeOffset) "Binary smoke test runs AFTER bundle is moved out of temp ($firstStageOffset < $firstSmokeOffset)" + } +} + +# --------------------------------------------------------------------------- +# Test 3: Run install.ps1 end-to-end into an isolated prefix. +# --------------------------------------------------------------------------- + +function Invoke-InstallScript { + param( + [Parameter(Mandatory=$true)][string]$Version, + [Parameter(Mandatory=$true)][string]$BinDir, + [Parameter(Mandatory=$true)][string]$TmpDir + ) + + $savedVersion = $env:VERSION + $savedInstallDir = $env:APM_INSTALL_DIR + $savedTempDir = $env:APM_TEMP_DIR + $savedSkipChecksum = $env:APM_SKIP_CHECKSUM + + try { + $env:VERSION = $Version + $env:APM_INSTALL_DIR = $BinDir + $env:APM_TEMP_DIR = $TmpDir + Remove-Item Env:APM_SKIP_CHECKSUM -ErrorAction SilentlyContinue + + & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $InstallScript | Out-Host + return $LASTEXITCODE + } finally { + if ($null -ne $savedVersion) { $env:VERSION = $savedVersion } else { Remove-Item Env:VERSION -ErrorAction SilentlyContinue } + if ($null -ne $savedInstallDir) { $env:APM_INSTALL_DIR = $savedInstallDir } else { Remove-Item Env:APM_INSTALL_DIR -ErrorAction SilentlyContinue } + if ($null -ne $savedTempDir) { $env:APM_TEMP_DIR = $savedTempDir } else { Remove-Item Env:APM_TEMP_DIR -ErrorAction SilentlyContinue } + if ($null -ne $savedSkipChecksum) { $env:APM_SKIP_CHECKSUM = $savedSkipChecksum } else { Remove-Item Env:APM_SKIP_CHECKSUM -ErrorAction SilentlyContinue } + } +} + +function Get-ShimVersion { + param([string]$ShimPath) + $out = & cmd.exe /c "`"$ShimPath`" --version" 2>&1 + return @{ ExitCode = $LASTEXITCODE; Output = ($out | Out-String).Trim() } +} + +function New-IsolatedPrefix { + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("apm-install-test-" + [System.Guid]::NewGuid().ToString("N")) + $binDir = Join-Path $root "bin" + $tmpDir = Join-Path $root "tmp" + New-Item -ItemType Directory -Force -Path $binDir | Out-Null + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + return @{ Root = $root; BinDir = $binDir; TmpDir = $tmpDir } +} + +# --------------------------------------------------------------------------- +# Test 3: End-to-end install into an isolated prefix (fresh install path). +# --------------------------------------------------------------------------- + +function Test-EndToEndInstall { + Write-Step "Test 3: End-to-end install of APM $PinnedVersion into isolated prefix" + + $prefix = New-IsolatedPrefix + try { + Write-Info "Running install.ps1 (VERSION=$PinnedVersion, APM_INSTALL_DIR=$($prefix.BinDir), APM_TEMP_DIR=$($prefix.TmpDir))" + $exitCode = Invoke-InstallScript -Version $PinnedVersion -BinDir $prefix.BinDir -TmpDir $prefix.TmpDir + Assert-True ($exitCode -eq 0) "install.ps1 exits 0 (got $exitCode)" + + $shim = Join-Path $prefix.BinDir "apm.cmd" + Assert-True (Test-Path $shim) "Shim written to $shim" + + if (Test-Path $shim) { + $shimText = Get-Content $shim -Raw + $releaseRoot = Join-Path $prefix.BinDir "..\releases" | Resolve-Path -ErrorAction SilentlyContinue + if ($releaseRoot) { + Assert-True ($shimText -match [regex]::Escape($releaseRoot.Path)) "Shim points into per-user releases dir ($($releaseRoot.Path)), not temp" + } + Assert-True ($shimText -notmatch [regex]::Escape($prefix.TmpDir)) "Shim does NOT point into APM_TEMP_DIR ($($prefix.TmpDir))" + + $ver = Get-ShimVersion -ShimPath $shim + Assert-True ($ver.ExitCode -eq 0) "apm.cmd --version exits 0 (got $($ver.ExitCode); output: $($ver.Output))" + Assert-True ($ver.Output -match $PinnedVersion.TrimStart("v")) "apm.cmd --version reports $PinnedVersion" + } + + $leftover = Get-ChildItem -Path $prefix.TmpDir -Filter "apm-install-*" -Directory -ErrorAction SilentlyContinue + Assert-True (-not $leftover) "No leftover apm-install-* directory in APM_TEMP_DIR" + } finally { + Remove-Item -Recurse -Force $prefix.Root -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# Test 4a: Cross-version upgrade. Install OlderVersion, then PinnedVersion, +# into the same prefix. The shim must end up pointing at PinnedVersion's +# release dir and `apm --version` must report PinnedVersion. +# --------------------------------------------------------------------------- + +function Test-CrossVersionUpgrade { + Write-Step "Test 4a: Cross-version upgrade $OlderVersion -> $PinnedVersion in same prefix" + + $prefix = New-IsolatedPrefix + try { + Write-Info "Step 1: install $OlderVersion" + $exit1 = Invoke-InstallScript -Version $OlderVersion -BinDir $prefix.BinDir -TmpDir $prefix.TmpDir + Assert-True ($exit1 -eq 0) "Step 1 install.ps1 exits 0 (got $exit1)" + + $shim = Join-Path $prefix.BinDir "apm.cmd" + $ver1 = Get-ShimVersion -ShimPath $shim + Assert-True ($ver1.Output -match $OlderVersion.TrimStart("v")) "Step 1: apm.cmd --version reports $OlderVersion (got: $($ver1.Output))" + + Write-Info "Step 2: install $PinnedVersion over the existing install" + $exit2 = Invoke-InstallScript -Version $PinnedVersion -BinDir $prefix.BinDir -TmpDir $prefix.TmpDir + Assert-True ($exit2 -eq 0) "Step 2 install.ps1 exits 0 (got $exit2)" + + $ver2 = Get-ShimVersion -ShimPath $shim + Assert-True ($ver2.Output -match $PinnedVersion.TrimStart("v")) "Step 2: apm.cmd --version reports $PinnedVersion (got: $($ver2.Output))" + + $shimText = Get-Content $shim -Raw + Assert-True ($shimText -match [regex]::Escape($PinnedVersion)) "Step 2: shim references $PinnedVersion path" + + # Both release dirs may coexist (we only replace the matching tag), + # but the staging/backup helper dirs from the second install MUST be + # cleaned up. + $releasesDir = Join-Path $prefix.BinDir "..\releases" | Resolve-Path + $leftoverStaging = Get-ChildItem -Path $releasesDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*.new-*" -or $_.Name -like "*.old-*" } + Assert-True (-not $leftoverStaging) "No leftover .new-* / .old-* staging/backup dirs after upgrade" + } finally { + Remove-Item -Recurse -Force $prefix.Root -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# Test 4b: Same-version reinstall. This is the path that exercises the +# stage -> move-existing-aside -> promote -> delete-backup branch in +# install.ps1, because $releaseDir already exists for the same tag. +# --------------------------------------------------------------------------- + +function Test-SameVersionReinstall { + Write-Step "Test 4b: Same-version reinstall of $PinnedVersion exercises promote/backup branch" + + $prefix = New-IsolatedPrefix + try { + Write-Info "Step 1: install $PinnedVersion" + $exit1 = Invoke-InstallScript -Version $PinnedVersion -BinDir $prefix.BinDir -TmpDir $prefix.TmpDir + Assert-True ($exit1 -eq 0) "Step 1 install.ps1 exits 0 (got $exit1)" + + $releasesDir = Join-Path $prefix.BinDir "..\releases" | Resolve-Path + $releaseDir = Join-Path $releasesDir $PinnedVersion + Assert-True (Test-Path $releaseDir) "Release dir exists after first install ($releaseDir)" + $firstExe = Join-Path $releaseDir "apm.exe" + $firstStamp = (Get-Item $firstExe).LastWriteTimeUtc + + Write-Info "Step 2: reinstall $PinnedVersion (must rename releaseDir aside, promote staging, delete backup)" + $exit2 = Invoke-InstallScript -Version $PinnedVersion -BinDir $prefix.BinDir -TmpDir $prefix.TmpDir + Assert-True ($exit2 -eq 0) "Step 2 install.ps1 exits 0 (got $exit2)" + + Assert-True (Test-Path $releaseDir) "Release dir still exists after reinstall" + $secondExe = Join-Path $releaseDir "apm.exe" + Assert-True (Test-Path $secondExe) "apm.exe present after reinstall" + + # apm.exe must be the freshly staged copy, not the original (the + # promote step renames the old release dir aside and moves the + # staging dir into place, so write time must be >= first stamp). + $secondStamp = (Get-Item $secondExe).LastWriteTimeUtc + Assert-True ($secondStamp -ge $firstStamp) "apm.exe write time advanced after reinstall ($firstStamp -> $secondStamp)" + + $ver = Get-ShimVersion -ShimPath (Join-Path $prefix.BinDir "apm.cmd") + Assert-True ($ver.ExitCode -eq 0) "apm.cmd --version exits 0 after reinstall (got $($ver.ExitCode))" + Assert-True ($ver.Output -match $PinnedVersion.TrimStart("v")) "apm.cmd --version reports $PinnedVersion after reinstall" + + $leftoverStaging = Get-ChildItem -Path $releasesDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*.new-*" -or $_.Name -like "*.old-*" } + Assert-True (-not $leftoverStaging) "No leftover .new-* / .old-* dirs after reinstall (rollback path didn't trigger and backup was deleted)" + } finally { + Remove-Item -Recurse -Force $prefix.Root -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# Test 5: Real `apm self-update` end-to-end. Install OlderVersion, then run +# the installed apm.cmd's self-update command. The installed apm downloads +# install.ps1 from aka.ms/apm-windows and runs it, exercising the whole +# launch path that issue #1389 originally broke. The fresh apm.cmd must +# report a version >= PinnedVersion afterwards. +# +# Caveat: self-update fetches the install.ps1 currently published at +# aka.ms/apm-windows (main branch), NOT the one in this PR. So this test +# proves the *launch path* and *upgrade flow* work end-to-end on a clean +# Windows runner. The new fixes in this PR are validated by Tests 1-4. +# --------------------------------------------------------------------------- + +function Test-SelfUpdateCommand { + Write-Step "Test 5: apm self-update end-to-end (start at $OlderVersion, expect upgrade)" + + $prefix = New-IsolatedPrefix + try { + Write-Info "Step 1: install $OlderVersion as the starting binary" + $exit1 = Invoke-InstallScript -Version $OlderVersion -BinDir $prefix.BinDir -TmpDir $prefix.TmpDir + Assert-True ($exit1 -eq 0) "Step 1 install.ps1 exits 0 (got $exit1)" + + $shim = Join-Path $prefix.BinDir "apm.cmd" + $ver1 = Get-ShimVersion -ShimPath $shim + Assert-True ($ver1.Output -match $OlderVersion.TrimStart("v")) "Step 1: apm.cmd --version reports $OlderVersion (got: $($ver1.Output))" + + Write-Info "Step 2: run apm self-update (downloads + dispatches install.ps1 from aka.ms/apm-windows)" + # Point the self-update temp file at our isolated prefix so we don't + # litter the runner's %LOCALAPPDATA% and so the staged install.ps1 + # has a writable temp dir. + # Install.ps1 honours APM_INSTALL_DIR and APM_TEMP_DIR; setting both + # here ensures the downloaded installer reuses our isolated prefix + # instead of writing to %LOCALAPPDATA%\Programs\apm. self-update's + # subprocess inherits these via os.environ -> external_process_env(). + $savedTempDir = $env:APM_TEMP_DIR + $savedInstallDir = $env:APM_INSTALL_DIR + $env:APM_TEMP_DIR = $prefix.TmpDir + $env:APM_INSTALL_DIR = $prefix.BinDir + try { + $output = & cmd.exe /c "`"$shim`" self-update" 2>&1 + $selfUpdateExit = $LASTEXITCODE + } finally { + if ($null -ne $savedTempDir) { $env:APM_TEMP_DIR = $savedTempDir } else { Remove-Item Env:APM_TEMP_DIR -ErrorAction SilentlyContinue } + if ($null -ne $savedInstallDir) { $env:APM_INSTALL_DIR = $savedInstallDir } else { Remove-Item Env:APM_INSTALL_DIR -ErrorAction SilentlyContinue } + } + + Write-Info "self-update output (last 20 lines):" + ($output | Out-String).Split("`n") | Select-Object -Last 20 | ForEach-Object { Write-Host " $_" } + + Assert-True ($selfUpdateExit -eq 0) "apm self-update exits 0 (got $selfUpdateExit)" + + $ver2 = Get-ShimVersion -ShimPath $shim + Assert-True ($ver2.ExitCode -eq 0) "apm.cmd --version exits 0 after self-update" + + # After self-update, version must have advanced past OlderVersion. + # We can't pin to PinnedVersion exactly because aka.ms/apm-windows + # always grabs the current latest, which may move ahead of this PR. + $oldNumeric = $OlderVersion.TrimStart("v") + Assert-True ($ver2.Output -notmatch [regex]::Escape($oldNumeric)) "apm.cmd --version no longer reports $OlderVersion after self-update (got: $($ver2.Output))" + } finally { + Remove-Item -Recurse -Force $prefix.Root -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +Write-Host "" +Write-Host "=================================================================" -ForegroundColor Blue +Write-Host " APM install.ps1 Windows integration test " -ForegroundColor Blue +Write-Host "=================================================================" -ForegroundColor Blue +Write-Host "" + +Test-Sha256Fallback +Test-MoveThenTestOrdering +Test-EndToEndInstall +Test-CrossVersionUpgrade +Test-SameVersionReinstall +Test-SelfUpdateCommand + +Write-Host "" +Write-Host "=================================================================" -ForegroundColor Blue +if ($Script:Failures.Count -eq 0) { + Write-Success "All install.ps1 integration tests passed." + exit 0 +} else { + Write-Fail "$($Script:Failures.Count) check(s) failed:" + foreach ($f in $Script:Failures) { Write-Host " - $f" -ForegroundColor Red } + exit 1 +}