diff --git a/esphome/components/store_yaml/__init__.py b/esphome/components/store_yaml/__init__.py index d9d3052ebd4..d950465d5d6 100644 --- a/esphome/components/store_yaml/__init__.py +++ b/esphome/components/store_yaml/__init__.py @@ -197,6 +197,22 @@ def _collect_sensitive_values() -> dict[str, _SensitiveValue]: return result +# Envelope path of the note recording includes that could not be captured. +UNCAPTURED_NOTE_PATH = "store_yaml_uncaptured.yaml" + + +def _uncaptured_note(unresolved: list[str]) -> tuple[str, bytes]: + """Comment-only YAML entry listing includes that could not be captured, so + a recovered config never silently appears complete. Emitted for both the + redacted and verbatim paths; user files are never modified to carry it.""" + text = ( + "# store_yaml: the following !include paths use substitutions and\n" + "# could not be captured; restore these files manually:\n" + + "".join(f"# {inc}\n" for inc in unresolved) + ) + return (UNCAPTURED_NOTE_PATH, text.encode("utf-8")) + + def _build_secrets_skeleton(keys: set[str]) -> bytes: parts = [SECRETS_SKELETON_HEADER] parts.extend(f'{key}: ""\n' for key in sorted(keys)) @@ -204,7 +220,7 @@ def _build_secrets_skeleton(keys: set[str]) -> bytes: def _generate_redacted_files( - entries: list[tuple[str, Path]], secret_rels: set[str], unresolved: list[str] + entries: list[tuple[str, Path]], secret_rels: set[str] ) -> list[tuple[str, bytes]]: """Re-generate each captured file from its parse tree with cv.sensitive values emitted as `!secret ` references, and replace secrets files @@ -248,19 +264,6 @@ def _generate_redacted_files( "embed secrets deliberately." ) - if unresolved: - # Record the gap inside the recovered config itself, not just in a - # compile-time log line: substitution-pathed includes can't be - # captured, so the user must restore those files manually. - # Discovery parses the entry file first, so entries[0] is the entry. - entry_rel = entries[0][0] - texts[entry_rel] = ( - "# store_yaml: the following !include paths use substitutions and\n" - "# could not be captured; restore these files manually:\n" - + "".join(f"# {inc}\n" for inc in unresolved) - + texts[entry_rel] - ) - skeleton = _build_secrets_skeleton(skeleton_keys) result = [ (rel, skeleton if rel in secret_rels else texts[rel].encode("utf-8")) @@ -337,7 +340,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_INCLUDE_SECRETS]: files = _read_files_verbatim(entries) else: - files = _generate_redacted_files(entries, secret_rels, discovered.unresolved) + files = _generate_redacted_files(entries, secret_rels) + if discovered.unresolved: + files.append(_uncaptured_note(discovered.unresolved)) envelope = _pack_envelope(files) compressed = zstd.compress(envelope, level=ZSTD_LEVEL) diff --git a/tests/unit_tests/components/test_store_yaml.py b/tests/unit_tests/components/test_store_yaml.py index 906ed32f078..95e5b182dee 100644 --- a/tests/unit_tests/components/test_store_yaml.py +++ b/tests/unit_tests/components/test_store_yaml.py @@ -10,10 +10,12 @@ import pytest from esphome import yaml_util from esphome.components.store_yaml import ( SECRETS_SKELETON_HEADER, + UNCAPTURED_NOTE_PATH, _gather_files, _generate_redacted_files, _pack_envelope, _read_files_verbatim, + _uncaptured_note, unpack_envelope, ) from esphome.core import CORE, EsphomeError @@ -49,7 +51,7 @@ def _sources( def _gather_redacted(discovered: DiscoveredYamlFiles) -> dict[str, bytes]: entries, secret_rels = _gather_files(discovered) - return dict(_generate_redacted_files(entries, secret_rels, discovered.unresolved)) + return dict(_generate_redacted_files(entries, secret_rels)) # --------------------------------------------------------------------------- @@ -272,14 +274,13 @@ def test_redacted_accepts_secret_only_values(project: Path) -> None: assert 'api_key: ""' in files["secrets.yaml"].decode() -def test_redacted_records_unresolved_includes_in_entry_file(project: Path) -> None: - """Substitution-pathed includes that can't be captured are noted inside the - recovered entry file, not just in a compile-time log line.""" - discovered = _sources(project, "entry.yaml", "secrets.yaml") - discovered.unresolved.append("${board}.yaml") - files = _gather_redacted(discovered) - text = files["entry.yaml"].decode() - assert text.startswith("# store_yaml: the following !include paths") +def test_uncaptured_note_lists_missing_includes() -> None: + """Substitution-pathed includes that can't be captured are recorded in a + dedicated envelope entry (both modes), not just a compile-time log line.""" + rel, content = _uncaptured_note(["${board}.yaml"]) + assert rel == UNCAPTURED_NOTE_PATH + text = content.decode() + assert text.startswith("# store_yaml:") assert "# ${board}.yaml" in text