Validate the board and flash-ld names; reject valued VTABLES defines; scope the damaged-cache recovery

The board name lands unquoted in two -D bodies and the flash linker
script name joins under the SDK and build ld dirs, so both are now
shape-validated like f_cpu. A VTABLES_IN_* define carrying a body would
split the compile line from the linker script and is refused. The
damaged-cache overwrite moves into a shared _write_generated helper
scoped to the comparison read: a corrupt existing copy is logged and
replaced (the testing-mode flash ld gets the same recovery), while a
genuine write failure still raises with its cause.
This commit is contained in:
J. Nick Koston
2026-08-22 15:05:58 -05:00
parent 58eea37e10
commit 49dcc89130
2 changed files with 64 additions and 9 deletions
+29 -9
View File
@@ -27,7 +27,7 @@ from esphome.arduino8266.framework import toolchain_tool
from esphome.build_helpers.ninja import shell_token as _shell_token
from esphome.components.esp8266 import build_surgery
from esphome.core import CORE, EsphomeError
from esphome.helpers import mkdir_p, write_file, write_file_if_changed
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.platformio.library import lex_build_flags
if TYPE_CHECKING:
@@ -243,6 +243,10 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
# SDK header's #error
if unknown := [k for k in vtables_knobs if k not in known_vtables]:
raise EsphomeError(f"Unknown VTABLES_IN_* define(s): {', '.join(unknown)}")
# A body (e.g. VTABLES_IN_FLASH=0) would split the compile line from the
# linker script, which always defines the bare name
if valued := [defines[k] for k in vtables_knobs if defines[k] not in (k, f"{k}=1")]:
raise EsphomeError(f"VTABLES_IN_* defines take no value: {', '.join(valued)}")
if len(vtables_knobs) > 1:
raise EsphomeError(
f"Conflicting VTABLES_IN_* defines: {', '.join(vtables_knobs)}"
@@ -319,6 +323,10 @@ def _defines_flags(
defines embed ``\"``), so they must be emitted unquoted; wrapping
them in ``_shell_token`` would deliver literal backslashes to gcc.
"""
if not re.fullmatch(r"[\w.-]+", board):
# The name lands unquoted in two -D bodies; reject it by name
# instead of corrupting the compile line
raise EsphomeError(f"Invalid board name {board!r}")
# Every supported board ships 80 MHz; board_build.f_cpu overrides
f_cpu = _pio_option("board_build.f_cpu", "80000000L")
if not re.fullmatch(r"\d+L?", f_cpu):
@@ -430,6 +438,21 @@ def _stat_sig(path: Path) -> str:
return f"unreadable:{os.urandom(8).hex()}"
def _write_generated(path: Path, content: str) -> None:
"""write_file_if_changed, replacing an unreadable existing copy.
The recovery is scoped to the comparison read: a damaged cached file is
logged and overwritten, while a genuine write failure still raises.
"""
try:
if path.is_file():
path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as err:
_LOGGER.warning("Replacing damaged generated file %s: %s", path, err)
path.unlink(missing_ok=True)
write_file_if_changed(path, content)
def generate_ld_scripts(
paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str
) -> None:
@@ -439,6 +462,9 @@ def generate_ld_scripts(
``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi
rate-table DRAM relocation, and enlarged memory segments in testing mode.
"""
if not re.fullmatch(r"[\w.-]+\.ld", flash_ld_name):
# Joined under the SDK and build ld dirs; never a path or traversal
raise EsphomeError(f"Invalid flash linker script name {flash_ld_name!r}")
framework = paths.framework
gcc = toolchain_tool(paths.toolchain, "gcc")
ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld")
@@ -525,13 +551,7 @@ def generate_ld_scripts(
except RuntimeError as err:
# Same changed-linker-script failure class as the ratetable
raise EsphomeError(str(err)) from err
try:
write_file_if_changed(output, content)
except EsphomeError:
# A non-UTF-8/unreadable cached script fails the comparison
# read; regeneration must overwrite it, not abort
output.unlink(missing_ok=True)
write_file(output, content)
_write_generated(output, content)
stamp.write_text(
f"{stamp_content} content={hashlib.sha256(content.encode('utf-8')).hexdigest()}",
encoding="utf-8",
@@ -569,4 +589,4 @@ def generate_ld_scripts(
except RuntimeError as err:
# Same changed-linker-script failure class as the ratetable
raise EsphomeError(str(err)) from err
write_file_if_changed(ld_dir / f"testing_{flash_ld_name}", patched_flash_ld)
_write_generated(ld_dir / f"testing_{flash_ld_name}", patched_flash_ld)
@@ -722,6 +722,41 @@ def test_generate_ld_scripts_unreadable_stamp_regenerates(tmp_path: Path) -> Non
mock_run.assert_called_once()
def test_vtables_valued_define_raises() -> None:
"""A VTABLES_IN_* body would split the compile line from the linker
script, which always defines the bare name."""
with pytest.raises(EsphomeError, match="take no value.*VTABLES_IN_FLASH=0"):
_resolve("-DVTABLES_IN_FLASH=0")
def test_defines_flags_invalid_board_raises() -> None:
"""The board name lands unquoted in two -D bodies; reject it by name."""
with pytest.raises(EsphomeError, match="Invalid board name"):
_defines_flags(_resolve(), "dout", "evil board", ())
def test_generate_ld_scripts_invalid_flash_ld_name_raises(tmp_path: Path) -> None:
"""The script name joins under the SDK and build ld dirs; a traversal
or path is rejected by name."""
paths = _make_framework(tmp_path)
_set_flags()
config = _resolve()
with pytest.raises(EsphomeError, match="Invalid flash linker script name"):
arduino8266.generate_ld_scripts(paths, config, "../evil.ld")
def test_write_generated_replaces_damaged_file(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-UTF-8 existing copy is logged and overwritten; the write path
still raises for real failures."""
target = tmp_path / "gen.ld"
target.write_bytes(b"\xff\xfe")
arduino8266._write_generated(target, "SECTIONS { }")
assert target.read_text(encoding="utf-8") == "SECTIONS { }"
assert "Replacing damaged generated file" in caplog.text
def test_generate_ld_scripts_edited_output_regenerates(tmp_path: Path) -> None:
"""The stamp records the content hash, so an externally edited cached
script regenerates instead of linking untrusted content."""