@@ -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