Skip to content

Commit b92ce44

Browse files
Merge branch 'main' into refactor/1076-strangler-fig
2 parents 796b5d9 + a4957b9 commit b92ce44

4 files changed

Lines changed: 591 additions & 18 deletions

File tree

.github/workflows/build-release.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ jobs:
109109
run: |
110110
uv run pwsh scripts/windows/build-binary.ps1
111111
112+
- name: Test install.ps1 end-to-end (Windows)
113+
if: matrix.platform == 'windows'
114+
shell: pwsh
115+
env:
116+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117+
run: |
118+
pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/windows/test-install-script.ps1
119+
112120
- name: Upload binary as workflow artifact
113121
uses: actions/upload-artifact@v4
114122
with:

docs/src/content/docs/getting-started/installation.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,30 @@ $env:APM_DEBUG = "1"
252252
apm install <package>
253253
```
254254

255+
### `Access is denied` running apm.exe on Windows (AppLocker / App Control for Business)
256+
257+
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.
258+
259+
The installer stages the binary under `%LOCALAPPDATA%\Programs\apm\releases\<tag>` **before** invoking it, so a single allow-list rule for that path is enough.
260+
261+
Ask your endpoint admin to add one of:
262+
263+
- **Path rule:** `%LOCALAPPDATA%\Programs\apm\*`
264+
- **Publisher / hash rule** for the released `apm.exe`
265+
266+
If you cannot change policy, set `APM_TEMP_DIR` to a directory your policy allows and retry:
267+
268+
```powershell
269+
$env:APM_TEMP_DIR = "$env:LOCALAPPDATA\Programs\apm\tmp"
270+
irm https://aka.ms/apm-windows | iex
271+
```
272+
273+
As a last resort, install via pip (runs from your Python user site):
274+
275+
```powershell
276+
pip install --user apm-cli
277+
```
278+
255279
## Next steps
256280

257281
See the [Quick Start](../quick-start/) to set up your first project.

install.ps1

Lines changed: 150 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,72 @@ function Write-ManualInstallHelp {
243243
Write-Host "Need help? Create an issue at: $GithubUrl/$ApmRepo/issues"
244244
}
245245

246+
function Get-Sha256Hex {
247+
# Stream-based SHA256 that works even when Get-FileHash is unavailable
248+
# (hardened hosts, $PSModuleAutoLoadingPreference='None', restricted sessions).
249+
# System.Security.Cryptography is a core .NET type allowed in ConstrainedLanguage.
250+
param([string]$Path)
251+
$cmd = Get-Command Get-FileHash -ErrorAction SilentlyContinue
252+
if (-not $cmd) {
253+
try {
254+
Import-Module Microsoft.PowerShell.Utility -ErrorAction Stop
255+
$cmd = Get-Command Get-FileHash -ErrorAction SilentlyContinue
256+
} catch {
257+
}
258+
}
259+
if ($cmd) {
260+
return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLower()
261+
}
262+
$stream = $null
263+
$hasher = $null
264+
try {
265+
$stream = [System.IO.File]::OpenRead($Path)
266+
$hasher = [System.Security.Cryptography.SHA256]::Create()
267+
$bytes = $hasher.ComputeHash($stream)
268+
$sb = New-Object System.Text.StringBuilder
269+
foreach ($b in $bytes) { [void]$sb.Append($b.ToString("x2")) }
270+
return $sb.ToString()
271+
} finally {
272+
if ($hasher) { $hasher.Dispose() }
273+
if ($stream) { $stream.Dispose() }
274+
}
275+
}
276+
277+
function Test-AccessDeniedError {
278+
# AppLocker / WDAC / App Control for Business denies CreateProcess on EXEs
279+
# under user-writable paths (e.g. %TEMP%, %LOCALAPPDATA%\Temp) with HRESULT
280+
# 0x80070005 (E_ACCESSDENIED), surfaced by PowerShell as "Access is denied".
281+
param([string]$Text)
282+
if (-not $Text) { return $false }
283+
return ($Text -match 'Access is denied' -or $Text -match '0x80070005')
284+
}
285+
286+
function Write-AppControlGuidance {
287+
param(
288+
[string]$Path,
289+
[string]$TargetInstallDir
290+
)
291+
Write-Host ""
292+
Write-ErrorText "The OS denied execution of $Path."
293+
Write-Host "This is the standard signature of an enterprise application control policy"
294+
Write-Host "(AppLocker or App Control for Business / WDAC) denying an unsigned binary"
295+
Write-Host "from a user-writable path."
296+
Write-Host ""
297+
Write-Info "Options to unblock:"
298+
if ($TargetInstallDir) {
299+
Write-Host " 1. Ask your endpoint admin to allow-list the final install path"
300+
Write-Host " ($TargetInstallDir) via an AppLocker/WDAC Path or Publisher rule."
301+
} else {
302+
Write-Host " 1. Ask your endpoint admin to allow-list apm.exe via an"
303+
Write-Host " AppLocker/WDAC Path or Publisher rule."
304+
}
305+
Write-Host " 2. Set APM_TEMP_DIR to a directory your policy permits, then retry:"
306+
Write-Host " `$env:APM_TEMP_DIR = `"`$env:LOCALAPPDATA\Programs\apm\tmp`""
307+
Write-Host " 3. Install via pip into your user site:"
308+
Write-Host " pip install --user apm-cli"
309+
Write-Host ""
310+
}
311+
246312
# ---------------------------------------------------------------------------
247313
# Banner
248314
# ---------------------------------------------------------------------------
@@ -487,7 +553,7 @@ try {
487553
if ($fetched -and (Test-Path $sha256Path)) {
488554
try {
489555
$expectedHash = (Get-Content $sha256Path -Raw).Trim().Split(" ")[0]
490-
$actualHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLower()
556+
$actualHash = Get-Sha256Hex -Path $zipPath
491557
if ($actualHash -ne $expectedHash) {
492558
Write-ErrorText "Checksum verification FAILED."
493559
Write-Host " Expected: $expectedHash"
@@ -515,49 +581,115 @@ try {
515581
}
516582

517583
# ------------------------------------------------------------------
518-
# Extract
584+
# Extract + stage + binary test + promote
585+
#
586+
# Order matters: AppLocker / App Control for Business commonly block
587+
# executable launch from %TEMP%. We move the extracted bundle to the
588+
# final per-user install root ($releasesDir, default
589+
# %LOCALAPPDATA%\Programs\apm\releases\<tag>) BEFORE invoking
590+
# apm.exe --version, so the binary test runs from the allow-listed
591+
# path that the shim will keep pointing at. Until promotion succeeds
592+
# we stage to a sibling `.new-<guid>` directory so a failed install
593+
# never destroys the currently working release. See issue #1389.
519594
# ------------------------------------------------------------------
520595

521596
Write-Info "Extracting package..."
522597
Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force
523598

524599
$packageDir = Join-Path $tempDir "apm-windows-x86_64"
525-
$exePath = Join-Path $packageDir "apm.exe"
526-
if (-not (Test-Path $exePath)) {
527-
Write-ErrorText "Extracted package is missing apm.exe."
600+
if (-not (Test-Path $packageDir)) {
601+
Write-ErrorText "Extracted package is missing the apm-windows-x86_64 directory."
528602
Write-Info "Attempting automatic fallback to pip..."
529603
if (Install-ViaPip) { exit 0 }
530604
Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo
531605
exit 1
532606
}
533607

534-
# ------------------------------------------------------------------
535-
# Binary test
536-
# ------------------------------------------------------------------
608+
$stagingDir = "$releaseDir.new-" + [System.Guid]::NewGuid().ToString("N")
609+
if (Test-Path $stagingDir) {
610+
Remove-Item -Recurse -Force $stagingDir
611+
}
612+
try {
613+
Move-Item -Path $packageDir -Destination $stagingDir -Force
614+
} catch {
615+
$stageError = "$_"
616+
Write-ErrorText "Failed to stage release at ${stagingDir}: $stageError"
617+
if (Test-AccessDeniedError -Text $stageError) {
618+
Write-AppControlGuidance -Path $stagingDir -TargetInstallDir $releaseDir
619+
}
620+
Write-Info "Attempting automatic fallback to pip..."
621+
if (Install-ViaPip) { exit 0 }
622+
Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo
623+
exit 1
624+
}
625+
626+
$stagedExe = Join-Path $stagingDir "apm.exe"
627+
if (-not (Test-Path $stagedExe)) {
628+
Write-ErrorText "Staged package is missing apm.exe."
629+
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue
630+
Write-Info "Attempting automatic fallback to pip..."
631+
if (Install-ViaPip) { exit 0 }
632+
Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo
633+
exit 1
634+
}
537635

538636
Write-Info "Testing binary..."
637+
$testFailure = $null
539638
try {
540-
$testOutput = & $exePath --version 2>&1
541-
if ($LASTEXITCODE -ne 0) { throw "exit code $LASTEXITCODE" }
639+
$testOutput = & $stagedExe --version 2>&1
640+
if ($LASTEXITCODE -ne 0) { throw "exit code $LASTEXITCODE - $testOutput" }
542641
Write-Success "Binary test successful: $testOutput"
543642
} catch {
544-
Write-ErrorText "Downloaded binary failed to run: $_"
545-
Write-Host ""
643+
$testFailure = "$_"
644+
}
645+
646+
if ($testFailure) {
647+
$denied = Test-AccessDeniedError -Text $testFailure
648+
Write-ErrorText "Downloaded binary failed to run: $testFailure"
649+
if ($denied) {
650+
Write-AppControlGuidance -Path $stagedExe -TargetInstallDir $releaseDir
651+
}
652+
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue
546653
Write-Info "Attempting automatic fallback to pip..."
547654
if (Install-ViaPip) { exit 0 }
548655
Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo
549656
exit 1
550657
}
551658

552-
# ------------------------------------------------------------------
553-
# Install
554-
# ------------------------------------------------------------------
555-
659+
# Promote: rename the existing release aside, then rename the staged
660+
# tree into place. Win32 has no truly atomic directory replacement, so
661+
# there is still a small gap where neither path exists; doing it this
662+
# way minimizes that gap and lets us roll back if the second rename
663+
# fails. Concurrent apm invocations during that window will fail and
664+
# need a retry — acceptable for an install/self-update operation.
665+
$backupDir = $null
556666
if (Test-Path $releaseDir) {
557-
Remove-Item -Recurse -Force $releaseDir
667+
$backupDir = "$releaseDir.old-" + [System.Guid]::NewGuid().ToString("N")
668+
try {
669+
Move-Item -Path $releaseDir -Destination $backupDir -Force
670+
} catch {
671+
Write-ErrorText "Failed to move existing release aside: $_"
672+
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue
673+
Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo
674+
exit 1
675+
}
676+
}
677+
678+
try {
679+
Move-Item -Path $stagingDir -Destination $releaseDir -Force
680+
} catch {
681+
Write-ErrorText "Failed to promote staged release: $_"
682+
if ($backupDir -and (Test-Path $backupDir)) {
683+
Move-Item -Path $backupDir -Destination $releaseDir -Force -ErrorAction SilentlyContinue
684+
}
685+
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue
686+
Write-ManualInstallHelp -GithubUrl $githubUrl -ApmRepo $apmRepo
687+
exit 1
558688
}
559689

560-
Move-Item -Path $packageDir -Destination $releaseDir
690+
if ($backupDir -and (Test-Path $backupDir)) {
691+
Remove-Item -Recurse -Force $backupDir -ErrorAction SilentlyContinue
692+
}
561693

562694
$shimPath = Join-Path $binDir "apm.cmd"
563695
$shimContent = "@echo off`r`n`"$releaseDir\apm.exe`" %*`r`n"

0 commit comments

Comments
 (0)