Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions scripts/tests/test-install-ps1-gitbash-compatibility.ps1
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions tests/tools/test_find_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,62 @@ def fake_starts(path: str) -> bool:
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",
Expand Down
Loading
Loading