[core] Make config-hash independent of machine-local paths (#17523)

This commit is contained in:
J. Nick Koston
2026-07-11 14:30:47 -10:00
committed by GitHub
parent 65353006c8
commit c0636e2bf7
5 changed files with 143 additions and 9 deletions
+18 -1
View File
@@ -8,6 +8,7 @@ import re
from typing import TYPE_CHECKING, Any
from esphome.const import (
CONF_BUILD_PATH,
CONF_COMMENT,
CONF_ESPHOME,
CONF_ETHERNET,
@@ -731,12 +732,28 @@ class EsphomeCore:
The hash is computed lazily and cached for performance.
Uses sort_keys=True to ensure deterministic ordering.
The hash must be reproducible across machines so the device builder
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.
"""
if self._config_hash is None:
from esphome import yaml_util
from esphome.helpers import fnv1a_32bit_hash
config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True)
config = dict(self.config)
if (esphome_conf := config.get(CONF_ESPHOME)) is not None:
esphome_conf = dict(esphome_conf)
esphome_conf.pop(CONF_BUILD_PATH, None)
config[CONF_ESPHOME] = esphome_conf
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,
)
self._config_hash = fnv1a_32bit_hash(config_str)
return self._config_hash
+30 -6
View File
@@ -840,17 +840,22 @@ def _load_yaml_internal_with_type(
loader.dispose()
def dump(dict_, show_secrets=False, sort_keys=False):
"""Dump YAML to a string and remove null."""
def dump(dict_, show_secrets=False, sort_keys=False, relative_to: 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.
"""
if show_secrets:
_SECRET_VALUES.clear()
_SECRET_CACHE.clear()
# Per-call subclass so the redaction flag doesn't leak across calls.
# Per-call subclass so the flags don't leak across calls.
# (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML
# processing is single-threaded today, so this isolates only the flag.)
# processing is single-threaded today, so this isolates only the flags.)
class _Dumper(ESPHomeDumper):
_redact_sensitive = not show_secrets
_relative_to = relative_to
return yaml.dump(
dict_,
@@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str:
class ESPHomeDumper(yaml.SafeDumper):
# Default for the base class; per-call subclass in ``dump()`` overrides.
# Defaults for the base class; per-call subclass in ``dump()`` overrides.
# When True, ``represent_sensitive`` wraps values in ANSI conceal codes.
_redact_sensitive: bool = False
# When set, ``represent_path`` dumps Path values relative to this
# 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
def represent_mapping(self, tag, mapping, flow_style=None):
value = []
@@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper):
return self.represent_secret(value)
return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value))
def represent_path(self, value: Path) -> yaml.ScalarNode:
if self._relative_to is not None:
# Normalize both sides lexically (no symlink resolution) so ".."
# segments do not defeat the prefix match, and walk up so files
# referenced outside the anchor directory stay relative too. A
# 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))
with suppress(ValueError):
path = path.relative_to(
os.path.normpath(self._relative_to), walk_up=True
)
return self.represent_stringify(path.as_posix())
return self.represent_stringify(value)
def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode:
# Only the redact-and-not-a-secret branch is unique to sensitive
# values; otherwise let ``represent_stringify`` handle ``!secret``
@@ -1138,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend)
ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove)
ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id)
ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify)
ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify)
ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path)
ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file)
+42
View File
@@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None:
assert hash1 != hash2
def test_config_hash_ignores_build_path() -> None:
"""Test that config_hash does not depend on the build_path value.
build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must
not make the hash differ between machines.
"""
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}}
hash1 = CORE.config_hash
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
"""Test that Path values under the config dir hash the same everywhere.
Simulates the same project checked out at two different locations; the
absolute paths differ but the layout relative to the config dir is the
same, so the hashes must match.
"""
dir1 = tmp_path / "machine_a" / "project"
dir2 = tmp_path / "machine_b" / "somewhere" / "else"
dir1.mkdir(parents=True)
dir2.mkdir(parents=True)
CORE.reset()
CORE.config_path = dir1 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"}
hash1 = CORE.config_hash
CORE.reset()
CORE.config_path = dir2 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"}
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(
+2 -2
View File
@@ -167,9 +167,9 @@ def setup_core(
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
if tmp_path is not None:
CORE.config_path = str(tmp_path / f"{name}.yaml")
CORE.config_path = tmp_path / f"{name}.yaml"
CORE.name = name
CORE.build_path = str(tmp_path / ".esphome" / "build" / name)
CORE.build_path = tmp_path / ".esphome" / "build" / name
@pytest.fixture
+51
View File
@@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None:
assert value == "hunter2"
def test_dump_path_without_relative_to_is_unchanged() -> None:
"""Test that Path values dump as str(path) when relative_to is not given."""
path = Path("some") / "dir" / "file.ttf"
output = yaml_util.dump({"file": path})
assert output.strip() == f"file: {path}"
def test_dump_path_relative_to_anchor_dir() -> None:
"""Test that Path values under relative_to dump as relative POSIX paths."""
anchor = Path("/config/esphome").absolute()
data = {"file": anchor / "fonts" / "arial.ttf"}
output = yaml_util.dump(data, relative_to=anchor)
assert output.strip() == "file: fonts/arial.ttf"
def test_dump_path_outside_anchor_dir_walks_up() -> None:
"""Test that Path values outside relative_to walk up with ".." segments."""
anchor = Path("/config/esphome").absolute()
outside = Path("/config/fonts/file.ttf").absolute()
output = yaml_util.dump({"file": outside}, relative_to=anchor)
assert output.strip() == "file: ../fonts/file.ttf"
def test_dump_path_with_dotdot_segments_is_normalized() -> None:
"""Test that ".." segments do not defeat relativization.
A path like /config/other/../esphome/fonts/x.ttf is under the anchor
once normalized, so it must dump as a plain relative path.
"""
anchor = Path("/config/esphome").absolute()
path = Path("/config/other/../esphome/fonts/x.ttf").absolute()
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: fonts/x.ttf"
def test_dump_path_dotdot_reference_outside_anchor() -> None:
"""Test the relative_config_path("../...") shape stays relative."""
anchor = Path("/config/esphome").absolute()
path = anchor / ".." / "shared" / "font.ttf"
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: ../shared/font.ttf"
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()
path = anchor / "fonts" / "arial.ttf"
assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor)
assert yaml_util.dump({"file": path}).strip() == f"file: {path}"
def test_dump__redacts_sensitive_str_by_default() -> None:
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
assert "\\033[8mhunter2\\033[28m" in out