Skip to content
Closed
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
33 changes: 31 additions & 2 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,40 @@ def _get_platform_default_hermes_home() -> Path:
"""Return the platform-native default Hermes home path."""
if sys.platform == "win32":
local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
local_appdata = _normalize_msys_env_path(local_appdata)
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
return base / "hermes"
return Path.home() / ".hermes"


def _normalize_msys_env_path(value: str) -> str:
"""Convert an MSYS/POSIX-form Windows path to native Windows form.

Under git-bash/MSYS, Win32 env vars are rewritten to POSIX form when
spawning native Python -- ``HERMES_HOME`` arrives as ``/c/Users/<user>/...``
instead of ``C:/Users/<user>/...``. A bare ``Path(value)`` then mangles the
leading ``/c`` into a rooted relative path (``\\c\\Users\\...``) pointing at
a non-existent shadow tree, breaking board enumeration and allowing junk
``C:\\c\\...`` dirs to be created.

This normalizes only the narrow, intentional MSYS form on win32:
``/<ASCII drive letter>/...`` -> ``<UPPER DRIVE>:/...``. Every other value
-- native drive paths (slash or backslash), UNC paths, custom root-relative
paths, Unicode non-drive paths, and all non-Windows values -- passes through
unchanged. A bare ``/c`` (no trailing slash) is also left unchanged.
"""
if (
sys.platform == "win32"
and len(value) >= 3
and value[0] == "/"
and value[1].isascii()
and value[1].isalpha()
and value[2] == "/"
):
return f"{value[1].upper()}:/{value[3:]}"
return value


def _hermes_home_from_env() -> Path:
"""Resolve HERMES_HOME from the process environment only.

Expand All @@ -70,7 +99,7 @@ def _hermes_home_from_env() -> Path:
"""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
return Path(_normalize_msys_env_path(val))
return _get_platform_default_hermes_home()


Expand Down Expand Up @@ -208,7 +237,7 @@ def get_default_hermes_root() -> Path:
if not env_home:
result = native_home
else:
env_path = Path(env_home)
env_path = Path(_normalize_msys_env_path(env_home))
try:
env_path.resolve().relative_to(native_home.resolve())
# HERMES_HOME is under ~/.hermes (normal or profile mode)
Expand Down
104 changes: 104 additions & 0 deletions tests/test_hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1099,3 +1099,107 @@ def fake_heal():
# The flag is set, so the once-per-process budget is spent.
assert heal_hermes_managed_node() is False
assert calls["n"] == 1


class TestMsysEnvPathNormalization:
"""_normalize_msys_env_path() converts only the intentional MSYS drive form.

Under git-bash/MSYS, Win32 env vars arrive as ``/c/Users/...`` instead of
``C:/Users/...``. A bare ``Path()`` mangles the leading ``/c`` into a rooted
relative path (``\\c\\Users\\...``) pointing at a non-existent shadow tree.
The helper converts ``/<ASCII drive letter>/...`` -> ``<UPPER DRIVE>:/...``
on win32 only, leaving every other form unchanged.
"""

# --- win32: MSYS drive paths are converted ---

def test_direct_msys_hermes_home(self):
assert hermes_constants._normalize_msys_env_path(
"/c/Users/alex/AppData/Local/hermes"
) == "C:/Users/alex/AppData/Local/hermes"

def test_msys_hermes_home_via_process_home(self, monkeypatch):
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
monkeypatch.setenv("HERMES_HOME", "/c/Users/alex/AppData/Local/hermes")
assert hermes_constants.get_process_hermes_home() == Path(
"C:/Users/alex/AppData/Local/hermes"
)

def test_msys_lowercase_drive_uppercased(self):
assert hermes_constants._normalize_msys_env_path(
"/d/other/path"
) == "D:/other/path"

def test_msys_profile_path_resolves_via_get_default_root(self, monkeypatch, tmp_path):
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
native_root = tmp_path / "LocalAppData" / "hermes"
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
# Build the MSYS form of <native_root>/profiles/coder: /c/Users/...
# becomes <drive>/Users/... i.e. the drive letter lowercased with no colon.
profile = native_root / "profiles" / "coder"
msys = f"/{profile.drive[0].lower()}/{str(profile)[2:].replace(chr(92), '/')}"
monkeypatch.setenv("HERMES_HOME", msys)
# After normalization the profile lives under the native root, so the
# root unwraps to the native home rather than treating it as Docker.
root = hermes_constants.get_default_hermes_root()
assert root == native_root

def test_msys_localappdata_with_hermes_home_unset(self, monkeypatch, tmp_path):
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setenv("LOCALAPPDATA", "/c/Users/alex/AppData/Local")
monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home")
monkeypatch.setattr(hermes_constants, "_profile_fallback_warned", False)
assert hermes_constants._get_platform_default_hermes_home() == (
Path("C:/Users/alex/AppData/Local") / "hermes"
)

# --- win32: native forms pass through unchanged ---

def test_native_forward_slash_drive_path_unchanged(self):
assert hermes_constants._normalize_msys_env_path(
"C:/Users/alex/AppData/Local/hermes"
) == "C:/Users/alex/AppData/Local/hermes"

def test_native_backslash_drive_path_unchanged(self):
assert hermes_constants._normalize_msys_env_path(
"C:\\Users\\alex\\AppData\\Local\\hermes"
) == "C:\\Users\\alex\\AppData\\Local\\hermes"

def test_unc_path_unchanged(self):
assert hermes_constants._normalize_msys_env_path(
"\\\\server\\share\\hermes"
) == "\\\\server\\share\\hermes"

def test_custom_root_relative_path_unchanged(self):
# /custom/hermes is not a drive path (third char isn't a slash).
assert hermes_constants._normalize_msys_env_path(
"/custom/hermes"
) == "/custom/hermes"

def test_bare_drive_slash_unchanged(self):
# A bare /c (no following slash) is intentionally left untouched.
assert hermes_constants._normalize_msys_env_path("/c") == "/c"

def test_unicode_non_drive_path_unchanged(self):
# /é/... is not an ASCII drive letter path, so it passes through.
assert hermes_constants._normalize_msys_env_path(
"/é/not-a-drive"
) == "/é/not-a-drive"

# --- non-win32: everything passes through unchanged ---

def test_non_windows_passthrough(self, monkeypatch):
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
assert hermes_constants._normalize_msys_env_path(
"/c/Users/alex/AppData/Local/hermes"
) == "/c/Users/alex/AppData/Local/hermes"

def test_non_windows_passthrough_via_hermes_home(self, monkeypatch, tmp_path):
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
monkeypatch.setenv("HERMES_HOME", "/home/alex/.hermes")
assert hermes_constants.get_process_hermes_home() == Path("/home/alex/.hermes")