From 3fea080ed8f7abc110bc86f9c72c09e1b0451770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Aug 2026 21:08:15 -0500 Subject: [PATCH] [core] Hash downloaded file paths at the default data dir location (#18824) --- esphome/core/__init__.py | 9 ++++-- esphome/yaml_util.py | 22 +++++++++++-- tests/unit_tests/core/test_config.py | 28 +++++++++++++++++ tests/unit_tests/test_yaml_util.py | 47 ++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 77efc91bef..6e3f91af22 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -783,7 +783,8 @@ class EsphomeCore: can compare a locally computed hash against the one a device advertises. Machine-local data is kept out of the input: build_path (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, - and Path values are dumped relative to the config directory. + and Path values are dumped relative to the config directory, with + the data directory always at its default ``.esphome`` location. """ if self._config_hash is None: from esphome import yaml_util @@ -794,11 +795,15 @@ class EsphomeCore: esphome_conf = dict(esphome_conf) esphome_conf.pop(CONF_BUILD_PATH, None) config[CONF_ESPHOME] = esphome_conf + relative_to = data_dir = None + if self.config_path is not None: + relative_to, data_dir = self.config_dir, self.data_dir config_str = yaml_util.dump( config, show_secrets=True, sort_keys=True, - relative_to=self.config_dir if self.config_path is not None else None, + relative_to=relative_to, + data_dir=data_dir, ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index c280e550c9..7c6cf691b9 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1057,11 +1057,19 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): +def dump( + dict_, + show_secrets=False, + sort_keys=False, + relative_to: Path | None = None, + data_dir: Path | None = None, +): """Dump YAML to a string and remove null. When ``relative_to`` is given, Path values are dumped relative to that - directory (POSIX form) so the output is machine independent. + directory (POSIX form) so the output is machine independent; Path values + under ``data_dir`` are then dumped as ``.esphome/``. ``data_dir`` + has no effect unless ``relative_to`` is also given. """ if show_secrets: _SECRET_VALUES.clear() @@ -1073,6 +1081,7 @@ def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets _relative_to = relative_to + _data_dir = data_dir return yaml.dump( dict_, @@ -1231,6 +1240,9 @@ class ESPHomeDumper(yaml.SafeDumper): # directory (in POSIX form) so the output does not depend on where the # config lives on the machine that produced it. _relative_to: Path | None = None + # Paths under this directory are dumped as ``.esphome/`` so the + # add-on's ``/data`` mount matches the CLI layout. + _data_dir: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1274,6 +1286,12 @@ class ESPHomeDumper(yaml.SafeDumper): # path that still cannot be relativized (e.g. a different drive) # keeps its POSIX form so separators stay stable across OSes. path = Path(os.path.normpath(value)) + # Checked first: the default data dir sits inside the config dir. + if self._data_dir is not None and path.is_relative_to( + data_dir := os.path.normpath(self._data_dir) + ): + rel = Path(".esphome") / path.relative_to(data_dir) + return self.represent_stringify(rel.as_posix()) with suppress(ValueError): path = path.relative_to( os.path.normpath(self._relative_to), walk_up=True diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 68b165c0d0..8ab3ad5d15 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1127,6 +1127,34 @@ def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: assert hash1 == hash2 +def test_config_hash_same_for_different_data_dirs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that downloaded file paths hash the same wherever data_dir lives.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + CORE.reset() + CORE.config_path = config_dir / "device.yaml" + CORE.config = { + "esphome": {"name": "test"}, + "file": config_dir / ".esphome" / "image" / "c44630d6", + } + hash1 = CORE.config_hash + + other_data_dir = tmp_path / "data" + CORE.reset() + monkeypatch.setenv("ESPHOME_DATA_DIR", str(other_data_dir)) + CORE.config_path = config_dir / "device.yaml" + CORE.config = { + "esphome": {"name": "test"}, + "file": other_data_dir / "image" / "c44630d6", + } + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 3bdbd04396..8e1f9c25c0 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1706,6 +1706,53 @@ def test_dump_path_dotdot_reference_outside_anchor() -> None: assert output.strip() == "file: ../shared/font.ttf" +@pytest.mark.parametrize( + "data_dir", + [ + pytest.param(Path("/config/.esphome"), id="cli"), + pytest.param(Path("/data"), id="addon"), + ], +) +def test_dump_path_under_data_dir_uses_default_location(data_dir: Path) -> None: + """Test that Path values under data_dir dump as .esphome/ for any layout.""" + anchor = Path("/config").absolute() + path = data_dir.absolute() / "image" / "c44630d6" + output = yaml_util.dump( + {"file": path}, relative_to=anchor, data_dir=data_dir.absolute() + ) + assert output.strip() == "file: .esphome/image/c44630d6" + + +def test_dump_path_equal_to_data_dir() -> None: + """Test that the data dir itself dumps as .esphome, matching the default layout.""" + anchor = Path("/config").absolute() + data_dir = Path("/data").absolute() + output = yaml_util.dump({"dir": data_dir}, relative_to=anchor, data_dir=data_dir) + assert output.strip() == "dir: .esphome" + default = yaml_util.dump( + {"dir": anchor / ".esphome"}, relative_to=anchor, data_dir=anchor / ".esphome" + ) + assert default == output + + +def test_dump_path_outside_data_dir_still_relative_to_anchor() -> None: + """Test that data_dir does not affect paths that are not under it.""" + anchor = Path("/config").absolute() + path = anchor / "fonts" / "arial.ttf" + output = yaml_util.dump( + {"file": path}, relative_to=anchor, data_dir=Path("/data").absolute() + ) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_data_dir_without_relative_to_is_unchanged() -> None: + """Test that data_dir alone does not change the output.""" + data_dir = Path("/data").absolute() + path = data_dir / "image" / "c44630d6" + output = yaml_util.dump({"file": path}, data_dir=data_dir) + assert output.strip() == f"file: {path}" + + def test_dump_relative_to_does_not_leak_between_calls() -> None: """Test that the relative_to flag is scoped to a single dump call.""" anchor = Path("/config/esphome").absolute()