From b192a1beff655eb359540d387e31d1e8aaa39203 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 13:34:11 -1000 Subject: [PATCH] Restore the outer secret collector on exit and bound decode_fields slices A nested secret_values_registered context hands the collector back to the outer one. decode_fields asserts on truncated length-delimited and fixed32 fields, and read_frame documents that it returns the oldest unconsumed frame. --- esphome/yaml_util.py | 3 ++- tests/integration/raw_api_client.py | 7 ++++++- tests/unit_tests/test_yaml_util.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 719c6cd9cce..db3327aa275 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1203,12 +1203,13 @@ def secret_values_registered(values: dict[str, str]) -> Generator[set[str]]: global _EMITTED_SECRET_NAMES # noqa: PLW0603 added = {v: n for v, n in values.items() if v not in _SECRET_VALUES} _SECRET_VALUES.update(added) + outer = _EMITTED_SECRET_NAMES emitted: set[str] = set() _EMITTED_SECRET_NAMES = emitted try: yield emitted finally: - _EMITTED_SECRET_NAMES = None + _EMITTED_SECRET_NAMES = outer # Only drop mappings this context still owns; a real !secret loaded # meanwhile may have registered the same value under its own name. for value, name in added.items(): diff --git a/tests/integration/raw_api_client.py b/tests/integration/raw_api_client.py index 46be7cd12d8..96b6af890f3 100644 --- a/tests/integration/raw_api_client.py +++ b/tests/integration/raw_api_client.py @@ -71,8 +71,10 @@ def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]: value, pos = varint(pos) elif wire_type == 2: length, pos = varint(pos) + assert pos + length <= len(buf), "truncated length-delimited field" value, pos = bytes(buf[pos : pos + length]), pos + length elif wire_type == 5: + assert pos + 4 <= len(buf), "truncated fixed32 field" value, pos = int.from_bytes(buf[pos : pos + 4], "little"), pos + 4 else: raise AssertionError(f"unexpected wire type {wire_type}") @@ -182,7 +184,10 @@ class RawApiClient: await asyncio.wait_for(_read_loop(), timeout) async def read_frame(self, msg_type: int, timeout: float = 10.0) -> bytes: - """Return the payload of the next frame of msg_type, dropping other frames before it.""" + """Return the payload of the oldest unconsumed frame of msg_type, dropping other frames before it. + + Frames received earlier, also through read_until_frame, count as unconsumed. + """ frames = self._parser.frames async def _read_loop() -> bytes: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index d8b394a2696..bd839ecdde0 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -2005,6 +2005,16 @@ def test_secret_values_registered_keeps_a_real_secret_loaded_meanwhile() -> None assert yaml_util.is_secret("hunter2") == "real_name" +def test_secret_values_registered_nested_keeps_the_outer_collector() -> None: + """An inner context does not stop the outer one from collecting names.""" + with yaml_util.secret_values_registered({"outer_value": "outer"}) as outer: + with yaml_util.secret_values_registered({"inner_value": "inner"}) as inner: + yaml_util.dump({"a": make_data_base("inner_value")}) + yaml_util.dump({"b": make_data_base("outer_value")}) + assert inner == {"inner"} + assert outer == {"outer"} + + def test_registered_secret_names() -> None: yaml_util._SECRET_VALUES["value_a"] = "name_a" assert "name_a" in yaml_util.registered_secret_names()