diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index f3219065923d1..6d00e54c6c698 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -263,10 +263,22 @@ def _load_hermes_env() -> None: env_path = home / ".env" if load_dotenv and env_path.exists(): try: - load_dotenv(str(env_path), override=True, encoding="utf-8") + # utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1 + # Set-Content -Encoding UTF8 / Notepad) and is a no-op for + # BOM-less UTF-8. Plain "utf-8" would keep U+FEFF on the first + # key name and silently drop it from os.environ under its + # canonical name. + load_dotenv(str(env_path), override=True, encoding="utf-8-sig") except UnicodeDecodeError: try: - load_dotenv(str(env_path), override=True, encoding="latin-1") + # utf-8-sig can't strip a BOM once we fall back to latin-1. + import codecs + import io + + raw = env_path.read_bytes() + if raw.startswith(codecs.BOM_UTF8): + raw = raw[len(codecs.BOM_UTF8) :] + load_dotenv(stream=io.StringIO(raw.decode("latin-1")), override=True) except Exception: pass except Exception: diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index d9cab6495e2bb..e77bfbbd7f7ae 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -234,3 +234,144 @@ def test_load_hermes_env_bridges_config_yaml_scalars(tmp_path, monkeypatch): assert os.environ.get("TELEGRAM_HOME_CHANNEL") == "5550001111" +def test_load_hermes_env_utf8_bom_preserves_first_key(tmp_path, monkeypatch): + """A leading UTF-8 BOM must not mangle the first .env key name. + + PowerShell 5.1 `Set-Content -Encoding UTF8` and Notepad prepend a BOM + (EF BB BF). With encoding=utf-8, python-dotenv kept U+FEFF on the first + key, so the credential never appeared under its canonical name and + `hermes send` failed to authenticate. + """ + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".env").write_bytes( + b"\xef\xbb\xbfSEND_BOM_BOT_TOKEN=tok-first\nSEND_BOM_SECOND=two\n" + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("SEND_BOM_BOT_TOKEN", raising=False) + monkeypatch.delenv("SEND_BOM_SECOND", raising=False) + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + send_cmd._load_hermes_env() + + assert os.environ.get("SEND_BOM_BOT_TOKEN") == "tok-first" + assert os.environ.get("SEND_BOM_SECOND") == "two" + assert "\ufeff" + "SEND_BOM_BOT_TOKEN" not in os.environ + +def test_load_hermes_env_bomless_utf8_still_loads(tmp_path, monkeypatch): + """BOM-less UTF-8 .env files must keep loading after the utf-8-sig switch.""" + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".env").write_bytes(b"SEND_PLAIN_TOKEN=plain-val\n") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("SEND_PLAIN_TOKEN", raising=False) + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + send_cmd._load_hermes_env() + + assert os.environ.get("SEND_PLAIN_TOKEN") == "plain-val" + +def test_load_hermes_env_latin1_fallback_still_loads(tmp_path, monkeypatch): + """Invalid UTF-8 bytes must still load via the latin-1 fallback path, + and a leading BOM must be stripped before the latin-1 decode so the + first key keeps its canonical name.""" + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + # BOM + valid first key + latin-1 é (0xE9, invalid UTF-8 alone) in a + # later value — forces the UnicodeDecodeError → latin-1 stream path. + (hermes_home / ".env").write_bytes( + b"\xef\xbb\xbfSEND_L1_TOKEN=tok-l1\nSEND_L1_NOTE=caf\xe9\n" + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("SEND_L1_TOKEN", raising=False) + monkeypatch.delenv("SEND_L1_NOTE", raising=False) + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + send_cmd._load_hermes_env() + + assert os.environ.get("SEND_L1_TOKEN") == "tok-l1" + assert os.environ.get("SEND_L1_NOTE") == "caf\xe9" + assert "\ufeff" + "SEND_L1_TOKEN" not in os.environ + +def test_load_hermes_env_latin1_fallback_overrides_shell(tmp_path, monkeypatch): + """The stream-based latin-1 fallback must keep override=True semantics: + the .env value wins over a stale shell export, same as the primary path.""" + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + # 0xE9 forces the UnicodeDecodeError \u2192 latin-1 stream fallback. + (hermes_home / ".env").write_bytes(b"SEND_OVR_TOKEN=caf\xe9-file\n") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("SEND_OVR_TOKEN", "stale-shell-value") + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + send_cmd._load_hermes_env() + + assert os.environ.get("SEND_OVR_TOKEN") == "caf\xe9-file" + +def test_load_hermes_env_fallback_read_error_is_swallowed(tmp_path, monkeypatch): + """An I/O error inside the latin-1 fallback must not escape \u2014 the send + path is best-effort by design and must never crash on a broken .env.""" + from pathlib import Path + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + # Invalid UTF-8 so the fallback (and its read_bytes call) is reached. + (hermes_home / ".env").write_bytes(b"SEND_ERR_TOKEN=caf\xe9\n") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _boom(self): + raise OSError("disk went away") + + monkeypatch.setattr(Path, "read_bytes", _boom) + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + # Should not raise. + send_cmd._load_hermes_env() + +def test_load_hermes_env_bom_only_env_is_noop(tmp_path, monkeypatch): + """A .env containing only a BOM must load zero vars without error.""" + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".env").write_bytes(b"\xef\xbb\xbf") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + before = dict(os.environ) + send_cmd._load_hermes_env() + + added = {k: v for k, v in os.environ.items() if k not in before} + assert "\ufeff" not in "".join(added)