mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Make config-hash independent of machine-local paths (#17523)
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user