diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 083f5842ba98..ed82dccc7f36 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -705,6 +705,100 @@ function Test-Python { return $false } +$script:GitInstallFailureReason = $null +$script:GitBashPath = $null +$script:GitBashProbeOutput = $null + +function Test-GitBashCompatibility { + <# + .SYNOPSIS + Verify that Git Bash can launch external MSYS programs, not just evaluate + shell builtins. Mandatory ASLR can allow bash.exe itself to start while + every child linked to msys-2.0.dll fails during fork/spawn. + #> + param([Parameter(Mandatory = $true)][string]$BashPath) + + $script:GitBashProbeOutput = $null + if (-not (Test-Path -LiteralPath $BashPath)) { + $script:GitBashProbeOutput = "bash.exe was not found at $BashPath" + return $false + } + + $process = New-Object System.Diagnostics.Process + try { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $BashPath + $startInfo.Arguments = '--noprofile --norc -c "/usr/bin/true; /usr/bin/cat --version >/dev/null"' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process.StartInfo = $startInfo + + if (-not $process.Start()) { + $script:GitBashProbeOutput = "bash.exe did not start" + return $false + } + if (-not $process.WaitForExit(15000)) { + try { $process.Kill() } catch { } + $script:GitBashProbeOutput = "Git Bash compatibility probe timed out" + return $false + } + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $script:GitBashProbeOutput = ("$stdout`n$stderr").Trim() + return ($process.ExitCode -eq 0) + } catch { + $script:GitBashProbeOutput = $_.Exception.Message + return $false + } finally { + $process.Dispose() + } +} + +function Test-MandatoryAslrEnabled { + <# Return true only when Windows reports system-wide ForceRelocateImages=ON. #> + try { + $cmd = Get-Command Get-ProcessMitigation -ErrorAction SilentlyContinue + if (-not $cmd) { return $false } + $mitigations = & $cmd -System + $value = $mitigations.Aslr.ForceRelocateImages + return ($null -ne $value -and $value.ToString().ToUpperInvariant() -eq "ON") + } catch { + return $false + } +} + +function Get-GitRootFromBashPath { + param([Parameter(Mandatory = $true)][string]$BashPath) + + $binDir = Split-Path -Path $BashPath -Parent + if ((Split-Path -Path $binDir -Leaf) -ine "bin") { + return (Split-Path -Path $binDir -Parent) + } + + $parent = Split-Path -Path $binDir -Parent + if ((Split-Path -Path $parent -Leaf) -ieq "usr") { + return (Split-Path -Path $parent -Parent) + } + return $parent +} + +function New-GitBashAslrFailureReason { + param([Parameter(Mandatory = $true)][string]$BashPath) + + $gitRoot = Get-GitRootFromBashPath -BashPath $BashPath + $escapedRoot = $gitRoot -replace "'", "''" + return @( + "Git Bash at $BashPath cannot launch required MSYS child processes because Windows Mandatory ASLR (ForceRelocateImages) is enabled system-wide. Reinstalling Git will not change this policy." + "Open PowerShell as Administrator and run:" + "`$gitRoot = '$escapedRoot'" + 'Get-Item "$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\*.exe" -ErrorAction SilentlyContinue | ForEach-Object { Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }' + "Then rerun Hermes setup. If the override is blocked or later re-applied, ask your Windows administrator to allow this per-program exception." + ) -join [Environment]::NewLine +} + function Install-Git { <# .SYNOPSIS @@ -737,13 +831,31 @@ function Install-Git { ``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh shell without a second PATH refresh. #> + $script:GitInstallFailureReason = $null Write-Info "Checking Git..." if (Get-Command git -ErrorAction SilentlyContinue) { $version = git --version Write-Success "Git found ($version)" Set-GitBashEnvVar - return $true + if ($script:GitBashPath -and (Test-GitBashCompatibility -BashPath $script:GitBashPath)) { + Write-Success "Git Bash can launch MSYS programs" + return $true + } + + if ($script:GitBashPath -and (Test-MandatoryAslrEnabled)) { + $script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath + Write-Err $script:GitInstallFailureReason + return $false + } + + if ($script:GitBashPath) { + $probeDetail = if ($script:GitBashProbeOutput) { ": $script:GitBashProbeOutput" } else { "" } + Write-Warn "System Git Bash could not launch required MSYS programs$probeDetail" + } else { + Write-Warn "Git is on PATH, but its Git Bash installation could not be located." + } + Write-Info "Trying a Hermes-managed PortableGit install instead..." } # Download PortableGit into $HermesHome\git. Always works as long as @@ -854,8 +966,25 @@ function Install-Git { $version = & $gitExe --version Write-Success "Git $version installed to $gitDir (portable, user-scoped)" Set-GitBashEnvVar + if (-not $script:GitBashPath) { + throw "PortableGit extraction did not produce a usable bash.exe" + } + if (-not (Test-GitBashCompatibility -BashPath $script:GitBashPath)) { + if (Test-MandatoryAslrEnabled) { + $script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath + } else { + $probeDetail = if ($script:GitBashProbeOutput) { " Probe output: $script:GitBashProbeOutput" } else { "" } + $script:GitInstallFailureReason = "Git Bash at $script:GitBashPath exists but cannot launch required MSYS programs.$probeDetail" + } + throw $script:GitInstallFailureReason + } + Write-Success "Git Bash can launch MSYS programs" return $true } catch { + if ($script:GitInstallFailureReason) { + Write-Err $script:GitInstallFailureReason + return $false + } Write-Err "Could not install portable Git: $_" Write-Info "" Write-Info "Fallback: install Git manually from https://git-scm.com/download/win" @@ -872,6 +1001,7 @@ function Set-GitBashEnvVar { ``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before PATH propagation completes in a newly-spawned shell. #> + $script:GitBashPath = $null $candidates = @() # Our own portable Git install is ALWAYS checked first, so a broken @@ -908,6 +1038,7 @@ function Set-GitBashEnvVar { if ($candidate -and (Test-Path $candidate)) { [Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User") $env:HERMES_GIT_BASH_PATH = $candidate + $script:GitBashPath = $candidate Write-Info "Set HERMES_GIT_BASH_PATH=$candidate" return } @@ -3293,7 +3424,12 @@ $InstallStages += @( # process), and throws cleanly if uv truly isn't installed yet. function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } } function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } } -function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } } +function Stage-Git { + if (-not (Install-Git)) { + if ($script:GitInstallFailureReason) { throw $script:GitInstallFailureReason } + throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" + } +} # Node is optional (browser tools degrade gracefully without it). Surface # failure to the JSON contract as skipped=true / reason rather than ok=true, # so a GUI driver consuming the manifest can distinguish "node ready" from diff --git a/scripts/tests/test-install-ps1-gitbash-compatibility.ps1 b/scripts/tests/test-install-ps1-gitbash-compatibility.ps1 new file mode 100644 index 000000000000..802ecb25e283 --- /dev/null +++ b/scripts/tests/test-install-ps1-gitbash-compatibility.ps1 @@ -0,0 +1,120 @@ +# Unit tests for install.ps1's Git Bash compatibility and Mandatory-ASLR +# guidance helpers. The installer itself is never executed: functions are +# extracted through the PowerShell AST to avoid downloads, PATH changes, or +# user-environment writes. + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) +$installScript = Join-Path $repoRoot "scripts\install.ps1" + +$failures = 0 +function Assert-Equal { + param($Expected, $Actual, [string]$Label) + if ($Expected -ne $Actual) { + Write-Host "FAIL: $Label" -ForegroundColor Red + Write-Host " expected: $Expected" + Write-Host " actual: $Actual" + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} +function Assert-True { + param($Condition, [string]$Label) + if (-not $Condition) { + Write-Host "FAIL: $Label" -ForegroundColor Red + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} + +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $installScript, [ref]$tokens, [ref]$parseErrors +) +if ($parseErrors.Count -gt 0) { + throw "install.ps1 has parse errors: $($parseErrors -join '; ')" +} + +foreach ($name in @( + "Test-GitBashCompatibility", + "Test-MandatoryAslrEnabled", + "Get-GitRootFromBashPath", + "New-GitBashAslrFailureReason", + "Stage-Git" +)) { + $fnAst = $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true + ) | Select-Object -First 1 + if (-not $fnAst) { throw "$name not found in install.ps1" } + . ([scriptblock]::Create($fnAst.Extent.Text)) +} + +Write-Host "" +Write-Host "-- Git root resolution --" +Assert-Equal "C:\Program Files\Git" ` + (Get-GitRootFromBashPath "C:\Program Files\Git\bin\bash.exe") ` + "PortableGit/full Git bin layout" +Assert-Equal "C:\Program Files\Git" ` + (Get-GitRootFromBashPath "C:\Program Files\Git\usr\bin\bash.exe") ` + "usr/bin layout" + +Write-Host "" +Write-Host "-- Mandatory ASLR detection --" +function Get-ProcessMitigation { + param([switch]$System) + [pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "ON" } } +} +Assert-Equal $true (Test-MandatoryAslrEnabled) "ForceRelocateImages ON is detected" +function Get-ProcessMitigation { + param([switch]$System) + [pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "NOTSET" } } +} +Assert-Equal $false (Test-MandatoryAslrEnabled) "ForceRelocateImages NOTSET is not diagnosed" + +Write-Host "" +Write-Host "-- Actionable remediation --" +$reason = New-GitBashAslrFailureReason "C:\Program Files\Git\bin\bash.exe" +Assert-True ($reason -match "Mandatory ASLR") "reason identifies Mandatory ASLR" +Assert-True ($reason -match "Reinstalling Git will not change") "reason rejects ineffective reinstall" +Assert-True ($reason -match [regex]::Escape("C:\Program Files\Git")) "reason uses selected Git root" +Assert-True ($reason -match "Set-ProcessMitigation") "reason includes targeted mitigation command" + +Write-Host "" +Write-Host "-- External-program probe --" +$gitCommand = Get-Command git -ErrorAction SilentlyContinue +$gitBash = $null +if ($gitCommand -and $gitCommand.Source) { + $gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent + foreach ($candidate in @("$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\bash.exe")) { + if (Test-Path -LiteralPath $candidate) { $gitBash = $candidate; break } + } +} +if ($gitBash) { + Assert-Equal $true (Test-GitBashCompatibility $gitBash) ` + "installed Git Bash launches external MSYS programs" +} else { + Write-Host "SKIP: no Git Bash found next to git.exe" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "-- Stage failure propagation --" +function Install-Git { return $false } +$script:GitInstallFailureReason = "specific Git Bash failure" +$stageError = $null +try { Stage-Git } catch { $stageError = $_.Exception.Message } +Assert-Equal "specific Git Bash failure" $stageError "Git stage preserves actionable reason" + +Write-Host "" +if ($failures -gt 0) { + Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red + exit 1 +} +Write-Host "All Git Bash compatibility tests passed." -ForegroundColor Green +exit 0 diff --git a/tests/tools/test_find_shell.py b/tests/tools/test_find_shell.py index b4bce6003150..d9b56bf31c4c 100644 --- a/tests/tools/test_find_shell.py +++ b/tests/tools/test_find_shell.py @@ -143,6 +143,62 @@ class TestFindBashSkipsBrokenCustomPath: assert _find_bash() == str(portable) +class TestGitBashExternalProgramProbe: + """The Windows health check must exercise MSYS child-process creation.""" + + def test_probe_runs_external_msys_programs(self, monkeypatch): + import tools.environments.local as local_mod + + local_mod._bash_starts_cache.clear() + local_mod._bash_probe_details_cache.clear() + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(local_mod.subprocess, "run", fake_run) + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + assert local_mod._bash_starts(r"C:\Git\bin\bash.exe") is True + assert calls[0][0][-1] == "/usr/bin/true; /usr/bin/cat --version >/dev/null" + + def test_aslr_failure_surfaces_targeted_windows_command( + self, tmp_path, monkeypatch + ): + import tools.environments.local as local_mod + + local_mod._bash_starts_cache.clear() + local_mod._bash_probe_details_cache.clear() + portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe" + portable.parent.mkdir(parents=True) + portable.write_text("", encoding="utf-8") + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setenv("HERMES_GIT_BASH_PATH", "") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + monkeypatch.setenv("ProgramFiles", str(tmp_path / "empty-program-files")) + monkeypatch.delenv("ProgramFiles(x86)", raising=False) + monkeypatch.setattr(local_mod.shutil, "which", lambda _name: None) + monkeypatch.setattr(local_mod, "_mandatory_aslr_enabled", lambda: True) + + def failed_probe(path: str) -> bool: + local_mod._bash_probe_details_cache[path] = ( + "dofork: child -1 - forked process died unexpectedly" + ) + return False + + monkeypatch.setattr(local_mod, "_bash_starts", failed_probe) + + with pytest.raises(RuntimeError) as exc_info: + local_mod._find_bash() + message = str(exc_info.value) + assert "Mandatory ASLR" in message + assert "Reinstalling Git will not change" in message + assert "Set-ProcessMitigation" in message + assert str(tmp_path / "hermes" / "git") in message + + @pytest.mark.skipif( not os.path.isfile("/bin/bash") or sys.platform != "darwin", reason="reproduces the macOS system-bash-3.2 login-shell swallow", diff --git a/tools/environments/local.py b/tools/environments/local.py index 4f182477fedc..37e760519a4f 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -1,6 +1,7 @@ """Local execution environment — spawn-per-call with session snapshot.""" import logging +import ntpath import os import platform import re @@ -653,8 +654,19 @@ def _find_bash() -> str: return candidate if candidates: - # Last resort: return the first path even if the probe failed, so the - # caller still sees the real bash error instead of "not found". + probe_details = "\n".join( + detail + for candidate in candidates + if (detail := _bash_probe_details_cache.get(candidate)) + ) + if _mandatory_aslr_enabled() is True or _looks_like_msys_spawn_failure( + probe_details + ): + raise RuntimeError(_git_bash_aslr_help(candidates[0], probe_details)) + + # Last resort for failures unrelated to the known MSYS/ASLR class: + # return the first path so the caller still sees the real bash error + # instead of the less useful "not found" message. return candidates[0] raise RuntimeError( @@ -665,14 +677,100 @@ def _find_bash() -> str: _bash_starts_cache: dict[str, bool] = {} +_bash_probe_details_cache: dict[str, str] = {} +_mandatory_aslr_enabled_cache: "bool | None" = None + +_BASH_EXTERNAL_PROGRAM_PROBE = "/usr/bin/true; /usr/bin/cat --version >/dev/null" + + +def _looks_like_msys_spawn_failure(details: str) -> bool: + """Match Git-for-Windows child-launch failures associated with ASLR.""" + lowered = details.lower() + return any( + marker in lowered + for marker in ( + "dofork:", + "child_copy:", + "0xc0000142", + "0xc0000005", + ) + ) + + +def _mandatory_aslr_enabled() -> "bool | None": + """Return Windows' system-wide ForceRelocateImages state when available.""" + global _mandatory_aslr_enabled_cache + if _mandatory_aslr_enabled_cache is not None: + return _mandatory_aslr_enabled_cache + + try: + powershell = shutil.which("powershell.exe") or "powershell.exe" + result = subprocess.run( + [ + powershell, + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()", + ], + capture_output=True, + text=True, + timeout=10, + creationflags=windows_hide_flags(), + ) + if result.returncode != 0: + return None + value = (result.stdout or "").strip().upper() + if value == "ON": + _mandatory_aslr_enabled_cache = True + return True + if value in {"OFF", "NOTSET"}: + _mandatory_aslr_enabled_cache = False + return False + except Exception as exc: + logger.debug("Could not query Windows Mandatory ASLR state: %s", exc) + return None + + +def _git_root_from_bash(bash: str) -> str: + """Resolve Git's root from either /bin or /usr/bin bash.""" + bin_dir = ntpath.dirname(ntpath.normpath(bash)) + if ntpath.basename(bin_dir).lower() != "bin": + return ntpath.dirname(bin_dir) + parent = ntpath.dirname(bin_dir) + if ntpath.basename(parent).lower() == "usr": + return ntpath.dirname(parent) + return parent + + +def _git_bash_aslr_help(bash: str, details: str = "") -> str: + """Build the targeted per-program Mandatory-ASLR remediation.""" + git_root = _git_root_from_bash(bash) + escaped_root = git_root.replace("'", "''") + detail_line = f"\nGit Bash probe output: {details[:500]}" if details else "" + return ( + f"Git Bash at {bash} cannot launch required MSYS child processes while " + "Windows Mandatory ASLR (ForceRelocateImages) is enabled, or its output " + f"matches that Git-for-Windows failure class.{detail_line}\n" + "Reinstalling Git will not change the Windows mitigation policy. Open " + "PowerShell as Administrator and run:\n" + f"$gitRoot = '{escaped_root}'\n" + 'Get-Item "$gitRoot\\bin\\bash.exe", "$gitRoot\\usr\\bin\\*.exe" ' + "-ErrorAction SilentlyContinue | ForEach-Object { " + "Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }\n" + "Then restart Hermes. If the override is blocked or later re-applied, " + "ask your Windows administrator to allow this per-program exception." + ) def _bash_starts(bash: str) -> bool: - """True if *bash* can run a trivial non-login command. + """True if *bash* can launch external MSYS programs. Uses ``--noprofile --norc`` so a broken login post-install (``Directory \\drivers\\etc``) does not falsely condemn an otherwise - usable bash. Cached per path for the process lifetime. + usable bash. The external ``true`` and ``cat`` calls are intentional: + a builtin-only ``exit 0`` probe misses Git-for-Windows fork/spawn failures + under system-wide Mandatory ASLR. Cached per path for the process lifetime. """ cached = _bash_starts_cache.get(bash) if cached is not None: @@ -680,7 +778,7 @@ def _bash_starts(bash: str) -> bool: try: result = subprocess.run( - [bash, "--noprofile", "--norc", "-c", "exit 0"], + [bash, "--noprofile", "--norc", "-c", _BASH_EXTERNAL_PROGRAM_PROBE], capture_output=True, text=True, timeout=15, @@ -689,8 +787,10 @@ def _bash_starts(bash: str) -> bool: ok = result.returncode == 0 if not ok: combined = f"{result.stdout or ''}{result.stderr or ''}" + _bash_probe_details_cache[bash] = combined.strip()[:2000] logger.debug("bash probe failed for %s: %s", bash, combined.strip()[:200]) except Exception as exc: + _bash_probe_details_cache[bash] = str(exc)[:2000] logger.debug("bash probe error for %s: %s", bash, exc) ok = False