"""Git Bash health probe + Mandatory-ASLR diagnostics (Windows), used by ``local._find_bash`` to pick a bash.exe that can actually launch MSYS children and to build the targeted remediation when none can.""" import logging import ntpath import platform import shutil import subprocess from hermes_cli._subprocess_compat import bounded_probe_run, windows_hide_flags _IS_WINDOWS = platform.system() == "Windows" # Same logger as the origin module so log routing is unchanged. logger = logging.getLogger("tools.environments.local") _bash_starts_cache: dict[str, bool] = {} _bash_probe_details_cache: dict[str, str] = {} _mandatory_aslr_enabled_cache: "bool | None" = None _BASH_PROBE_TIMEOUT = 15.0 # External ``true`` and ``cat`` are intentional: a builtin-only ``exit 0`` probe # misses Git-for-Windows fork/spawn failures under system-wide Mandatory ASLR. _BASH_EXTERNAL_PROGRAM_PROBE = "/usr/bin/true; /usr/bin/cat --version >/dev/null" _MSYS_SPAWN_FAILURE_MARKERS = ("dofork:", "child_copy:", "0xc0000142", "0xc0000005") 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 _MSYS_SPAWN_FAILURE_MARKERS) 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 cmd = [shutil.which("powershell.exe") or "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()"] try: result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, creationflags=windows_hide_flags(), stdin=subprocess.DEVNULL) except Exception as exc: logger.debug("Could not query Windows Mandatory ASLR state: %s", exc) return None if result.returncode != 0: return None value = (result.stdout or "").strip().upper() _mandatory_aslr_enabled_cache = {"ON": True, "OFF": False, "NOTSET": False}.get(value) return _mandatory_aslr_enabled_cache 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) return ntpath.dirname(parent) if ntpath.basename(parent).lower() == "usr" else parent def _git_bash_aslr_help(bash: str, details: str = "") -> str: """Build the targeted per-program Mandatory-ASLR remediation.""" escaped_root = _git_root_from_bash(bash).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 launch external MSYS programs (cached per path). ``--noprofile --norc`` so a broken login post-install (``Directory \\drivers\\etc``) does not falsely condemn an otherwise usable bash.""" if bash in _bash_starts_cache: return _bash_starts_cache[bash] try: # bounded_probe_run, not subprocess.run: on Windows run()'s post-timeout cleanup is an # unbounded communicate(), and the MSYS children of the probe (true/cat) can outlive the # killed bash holding the pipe write ends — the ACP host then wedged for minutes (#73403). # The probe's tree is killed and the drain bounded, so a slow host fails the probe fast # and _find_bash falls through to its last-resort candidate instead of hanging. # stdin=DEVNULL (inside bounded_probe_run) is also what keeps the probe off the ACP # host's stdin pipe: cygwin init's handle_to_fn/NtQueryObject stalls ~22 s on a pipe # file object with a read pending on it, and the host's stdin reader always has one # (@Aaaarminn's strace on #73403). stderr stays captured: the Mandatory-ASLR # remediation keys off bash's dofork:/child_copy: text. result = bounded_probe_run( [bash, "--noprofile", "--norc", "-c", _BASH_EXTERNAL_PROGRAM_PROBE], timeout=_BASH_PROBE_TIMEOUT, raise_on_spawn_failure=True) ok = result is not None and result.returncode == 0 if not ok: combined = (f"{result.stdout or ''}{result.stderr or ''}".strip() if result is not None else f"probe timed out after {_BASH_PROBE_TIMEOUT:g}s") _bash_probe_details_cache[bash] = combined[:2000] logger.debug("bash probe failed for %s: %s", bash, combined[: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 _bash_starts_cache[bash] = ok return ok