[core] Keep templated !include filenames as strings so Windows path normalization cannot corrupt them (#18549)

This commit is contained in:
J. Nick Koston
2026-08-20 11:32:15 -05:00
committed by GitHub
parent 7fe4399b94
commit a3ea77c2f1
5 changed files with 120 additions and 15 deletions
+55 -1
View File
@@ -29,8 +29,9 @@ from esphome.bundle import (
read_bundle_manifest,
remap_bundle_path,
)
from esphome.components.substitutions import do_substitution_pass
from esphome.core import CORE, EsphomeError
from esphome.yaml_util import force_load_include_files
from esphome.yaml_util import force_load_include_files, load_yaml
# ---------------------------------------------------------------------------
# Helpers
@@ -1277,6 +1278,59 @@ def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None:
assert "includes/empty.yaml" in paths
@pytest.mark.parametrize("enable_proxy", [True, False])
def test_bundle_roundtrip_templated_include_with_path_separator(
tmp_path: Path, enable_proxy: bool
) -> None:
r"""The issue-18545 flow: a Jinja !include whose branches contain "/" still
resolves after the bundle is extracted on the build server.
Windows is the leg that regresses: the raw expression text must survive
verbatim, or its separators get rewritten to "\" and Jinja decodes
sequences like "\b" as string escapes.
"""
config_dir = _setup_config_dir(
tmp_path,
files={
"includes/boards/board.yaml": (
"packages:\n"
' - !include ${ "bluetooth/bluetooth_proxy_single_core.yaml"'
' if enable_bluetooth_proxy else "../empty.yaml" }\n'
),
"includes/boards/bluetooth/bluetooth_proxy_single_core.yaml": (
"bluetooth_proxy:\n active: true\n"
),
"includes/empty.yaml": "{}\n",
},
)
(config_dir / "test.yaml").write_text(
"substitutions:\n"
f" enable_bluetooth_proxy: {str(enable_proxy).lower()}\n"
"esphome:\n name: test\n"
"packages:\n - !include includes/boards/board.yaml\n"
)
result = ConfigBundleCreator({}).create_bundle()
bundle_path = tmp_path / "device.esphomebundle.tar.gz"
bundle_path.write_bytes(result.data)
# Both conditional branches must ship in the bundle.
paths = [f.path for f in result.files]
assert "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml" in paths
assert "includes/empty.yaml" in paths
# Extract to a fresh directory and resolve the config from there, as a
# remote build server would.
extracted_config = extract_bundle(bundle_path, tmp_path / "remote")
config = do_substitution_pass(load_yaml(extracted_config))
board_pkg = config["packages"][0]["packages"][0]
if enable_proxy:
assert board_pkg == {"bluetooth_proxy": {"active": True}}
else:
assert board_pkg == {}
def test_discover_files_candidate_outside_config_dir_skipped(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
+19
View File
@@ -744,6 +744,25 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None:
substitutions.do_substitution_pass(config)
def test_include_filename_jinja_expression_with_path_separator(
tmp_path: Path,
) -> None:
"""A jinja !include whose string literals contain "/" resolves correctly (issue #18545)."""
main_file = tmp_path / "main.yaml"
main_file.write_text(
"substitutions:\n"
" enable_bluetooth_proxy: true\n"
"result: !include "
'${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }\n'
)
(tmp_path / "bluetooth").mkdir()
(tmp_path / "bluetooth" / "proxy.yaml").write_text("value: 42\n")
config = yaml_util.load_yaml(main_file)
config = substitutions.do_substitution_pass(config)
assert config["result"] == {"value": 42}
def test_raise_first_undefined_logs_extras_at_debug(
caplog: pytest.LogCaptureFixture,
) -> None:
+26 -1
View File
@@ -701,6 +701,31 @@ def test_include_file_has_unresolved_expressions(
assert include.has_unresolved_expressions() == expected
def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None:
"""The mapping !include form rejects a non-string 'file' with a clear error."""
entry = tmp_path / "entry.yaml"
entry.write_text("wifi: !include\n file: [not, a, string]\n")
with pytest.raises(EsphomeError, match="Include 'file' must be a string"):
yaml_util.load_yaml(entry)
def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> None:
"""A templated filename keeps its verbatim text (issue #18545)."""
parent = tmp_path / "main.yaml"
expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }'
include = yaml_util.IncludeFile(parent, expr, None, lambda _: {})
assert include.file == expr
assert include.has_unresolved_expressions()
assert repr(include) == f"IncludeFile({expr})"
def test_represent_include_file_templated() -> None:
"""Dumping a templated IncludeFile emits the raw expression unchanged."""
expr = '${ "a/b.yaml" if flag else "../c.yaml" }'
include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {})
assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n"
def test_include_in_list_context() -> None:
"""!include of a file returning a list is handled correctly,
including when that list itself contains a nested IncludeFile."""
@@ -1051,7 +1076,7 @@ class _StubInclude:
) -> None:
# Default parent lives in a nonexistent directory so unresolved
# stubs never glob real files during candidate expansion.
self.file = Path(file)
self.file = file
self.parent_file = parent_file or Path("/nonexistent/parent.yaml")
self._unresolved = unresolved
self._load_result = load_result if load_result is not None else {}