diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 8018280d5..9dea6e5fe 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -23,6 +23,12 @@ _CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY") # tests) don't spam the same warning multiple times. _WARNED_KEYS: set[str] = set() +# Paths we've already emitted a UTF-32 refuse-to-mangle warning for. +# load_hermes_dotenv can call _sanitize_env_file_if_needed multiple times +# for the same file (user env + project env + hot-reload); once per path +# is enough. +_WARNED_UTF32_PATHS: set[str] = set() + # Map of env-var name → source label ("bitwarden", etc.) for credentials # that were injected by an external secret source during load_hermes_dotenv(). # Used by setup / `hermes model` flows to label detected credentials so @@ -208,13 +214,16 @@ def _sanitize_env_file_if_needed(path: Path) -> None: if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE): # Lazy import keeps the module import block identical to #65124's # codecs/io additions so the two PRs auto-merge either order. - import logging + path_key = str(path.resolve()) + if path_key not in _WARNED_UTF32_PATHS: + _WARNED_UTF32_PATHS.add(path_key) + import logging - logging.getLogger(__name__).warning( - "Skipping .env sanitize for %s: UTF-32 BOM detected; " - "leaving file untouched to avoid corruption", - path, - ) + logging.getLogger(__name__).warning( + "Skipping .env sanitize for %s: UTF-32 BOM detected; " + "leaving file untouched to avoid corruption", + path, + ) return if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE): # "utf-16" uses the BOM to select endianness and strips it. diff --git a/tests/hermes_cli/test_env_loader.py b/tests/hermes_cli/test_env_loader.py index 719b272a5..a5a2ee752 100644 --- a/tests/hermes_cli/test_env_loader.py +++ b/tests/hermes_cli/test_env_loader.py @@ -276,6 +276,35 @@ def test_utf32_be_bom_leaves_file_untouched(tmp_path, caplog): assert any("UTF-32" in r.message for r in caplog.records) +def test_utf32_warning_fires_once_per_path(tmp_path, caplog, monkeypatch): + """Three sanitize calls on the same UTF-32 file → exactly one warning. + + Matches house style for warn-once (module-level seen-set, same class as + ``_WARNED_KEYS``): hot-reload / multi-entry load must not spam logs. + """ + import logging + + import hermes_cli.env_loader as env_loader + from hermes_cli.env_loader import _sanitize_env_file_if_needed + + # Isolate process-level seen-set so other tests' paths don't leak in. + monkeypatch.setattr(env_loader, "_WARNED_UTF32_PATHS", set()) + + env_file = tmp_path / ".env" + content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n" + raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le") + env_file.write_bytes(raw) + + with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"): + _sanitize_env_file_if_needed(env_file) + _sanitize_env_file_if_needed(env_file) + _sanitize_env_file_if_needed(env_file) + + utf32_warnings = [r for r in caplog.records if "UTF-32" in r.message] + assert len(utf32_warnings) == 1 + assert env_file.read_bytes() == raw + + def test_leading_replacement_char_does_not_rewrite(tmp_path): """errors=replace FFFD-on-first-line guard: do not persist mangling.