diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 101617de9ad..619311ed18b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13365,7 +13365,10 @@ def _render_distribution_plan(plan) -> None: if key == er.name: already = True break - except OSError: + except (OSError, UnicodeDecodeError): + # UnicodeDecodeError is a ValueError, not an OSError, so + # the old guard let a mis-encoded .env abort the whole + # install preview. Skip the pre-check instead. pass status = "✓ set" if already else ("needs setting" if er.required else "—") line = f" • {er.name} ({tag}, {status})" diff --git a/tests/hermes_cli/test_profile_install_env_encoding.py b/tests/hermes_cli/test_profile_install_env_encoding.py new file mode 100644 index 00000000000..f6e746c86f1 --- /dev/null +++ b/tests/hermes_cli/test_profile_install_env_encoding.py @@ -0,0 +1,72 @@ +"""Regression: the distribution-install preview must read .env as UTF-8. + +`_render_distribution_plan` inspects the target profile's `.env` to decide +whether a required env var is already set, so it doesn't nag the user. It +used `Path.read_text()` with no encoding, which: + + 1. defaults to the system locale (cp1251/GBK on Windows) and raises + `UnicodeDecodeError` on any non-ASCII byte — and the surrounding + `except OSError` does NOT catch that (it's a `ValueError`), so a + mis-encoded .env aborts the whole install preview; and + 2. even on a UTF-8 locale, a Notepad-added BOM prefixes the first key, + so the very first env var is mis-detected as "needs setting". + +The fix reads `utf-8-sig` and also catches `UnicodeDecodeError`. +""" + +from types import SimpleNamespace + +import pytest + +from hermes_cli.main import _render_distribution_plan + + +def _make_plan(target_dir, env_requires): + manifest = SimpleNamespace( + name="demo", + version="1.0.0", + description="", + author="", + hermes_requires="", + env_requires=env_requires, + ) + return SimpleNamespace( + manifest=manifest, + provenance="local", + target_dir=target_dir, + existing=False, + has_cron=False, + ) + + +def test_bom_prefixed_env_first_key_is_detected_as_set(tmp_path, monkeypatch, capsys): + """A required key on the first line of a BOM-prefixed .env reads as set.""" + monkeypatch.delenv("FOO_TOKEN", raising=False) + profile = tmp_path / "profile" + profile.mkdir() + # utf-8-sig write == UTF-8 with a leading BOM, exactly what Notepad emits. + (profile / ".env").write_text("FOO_TOKEN=abc\nBAR=1\n", encoding="utf-8-sig") + + plan = _make_plan(profile, [SimpleNamespace(name="FOO_TOKEN", required=True, description="")]) + _render_distribution_plan(plan) + + out = capsys.readouterr().out + foo_line = next(ln for ln in out.splitlines() if "FOO_TOKEN" in ln) + assert "✓ set" in foo_line, f"BOM must not hide the first key: {foo_line!r}" + + +def test_non_utf8_env_does_not_abort_the_preview(tmp_path, monkeypatch, capsys): + """A .env with invalid UTF-8 bytes must not crash the install preview.""" + monkeypatch.delenv("FOO_TOKEN", raising=False) + profile = tmp_path / "profile" + profile.mkdir() + # \xff is an invalid UTF-8 start byte -> UnicodeDecodeError on read. + (profile / ".env").write_bytes(b"FOO_TOKEN=\xff\xfe bad\n") + + plan = _make_plan(profile, [SimpleNamespace(name="FOO_TOKEN", required=True, description="")]) + + # Must return normally (old `except OSError` let UnicodeDecodeError escape). + _render_distribution_plan(plan) + + out = capsys.readouterr().out + assert any("FOO_TOKEN" in ln for ln in out.splitlines())