fix(cli): apply BOM-safe .env decoding to hermes send's private loader
send_cmd._load_hermes_env intentionally reimplements a minimal dotenv load (no secret-source pulls, no sanitize rewrite, get_hermes_home path resolution incl. Windows/profile override), so the shared-loader BOM fix is mirrored in place: utf-8-sig primary read, BOM strip before the latin-1 stream fallback. Claude-Session: https://claude.ai/code/session_01JPmJz5u1Bvtw4cCRvRWnYr
This commit is contained in:
parent
b76498ba07
commit
ece678db97
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue