From 54cd17591d6ef46b596ec52755550f0efce6399f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 10:15:52 -0500 Subject: [PATCH] Type-check extraScript, capture CPPPATH, surface version-less dependency drops A non-string extraScript now fails naming the library instead of an opaque TypeError, matching the hardening on every other manifest field. CPPPATH joins the captured keys and translates to -I flags anchored like LIBPATH, since the pipeline already routes -I to include dirs. Version-less dependency drops log at INFO until the arduino-backend reconciliation lands, and the wave-deferral comment now claims only what the guard actually saves. --- esphome/platformio/extra_script.py | 21 +++++++++--- esphome/platformio/library.py | 10 +++--- .../test_platformio_extra_script.py | 33 +++++++++++++++++-- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index f1847dc959..d1550a680b 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -36,6 +36,12 @@ def apply_extra_script( extra_script = component.data.get("build", {}).get("extraScript") if not extra_script: return + if not isinstance(extra_script, str): + # A list/dict value would raise an opaque TypeError on the join below + raise EsphomeError( + f"extraScript of library {component.name} must be a string, " + f"got {type(extra_script).__name__}" + ) # Resolve and confine to the library's source dir so a malicious # library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``). source_path = component.source_dir @@ -77,13 +83,16 @@ def apply_extra_script( # Keys we know how to translate back into ESPHome's build-flag pipeline. # Other env.Append kwargs are recorded but ignored downstream. -_CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}) +_CAPTURED_KEYS = frozenset( + {"CPPPATH", "LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"} +) @dataclass class ExtraScriptResult: """Build-var deltas captured from a PIO extra-script ``env.Append`` call.""" + cpppath: list[str] = field(default_factory=list) libpath: list[str] = field(default_factory=list) libs: list[str] = field(default_factory=list) cppdefines: list[str | tuple[str, str]] = field(default_factory=list) @@ -240,14 +249,18 @@ def captured_as_build_flags( return good library_root = library_dir.resolve() - for path in _strs(result.libpath, "LIBPATH"): + + def _anchored(path: str) -> str: # Anchor relative paths to library_dir; the script's CWD has been # restored by now resolved = (library_dir / path).resolve() try: - flags.append(f"-L{resolved.relative_to(library_root)}") + return str(resolved.relative_to(library_root)) except ValueError: - flags.append(f"-L{resolved}") + return str(resolved) + + flags.extend(f"-I{_anchored(path)}" for path in _strs(result.cpppath, "CPPPATH")) + flags.extend(f"-L{_anchored(path)}" for path in _strs(result.libpath, "LIBPATH")) flags.extend(f"-l{lib}" for lib in _strs(result.libs, "LIBS")) for define in result.cppdefines: # SCons also accepts dict/list CPPDEFINES; formatting those blind diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 79933c07e8..ce74d828da 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -1137,8 +1137,8 @@ def convert_libraries( node = nodes[key] if frozenset(node.requirements) != resolved_requirements[key]: # An earlier wave entry grew this node's requirements after - # the drain resolved it; downloading the superseded version - # would be wasted work, and the next wave re-resolves it + # the drain resolved it; skip parsing and walking a manifest + # the next wave will replace (its archive is already fetched) worklist.append(key) continue component.download(salt=salt, namespace=backend.cache_key) @@ -1205,9 +1205,9 @@ def convert_libraries( component.data.get("dependencies"), component.name ): if "version" not in dependency: - # Cannot resolve from the registry; the arduino-backend - # PR adds the reconciliation that reports real drops - _LOGGER.debug( + # Cannot resolve from the registry; common for bundled + # names (Wire, SPI) -- add_library() is the fix if real + _LOGGER.info( "Skip version-less dependency %r of %s", dependency.get("name"), component.name, diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index f7ac268bf4..dcb181caa3 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -302,6 +302,33 @@ def test_apply_extra_script_missing_script_raises(tmp_path) -> None: apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") +@pytest.mark.parametrize("bad", (["a.py"], {"esp32": "a.py"}), ids=("list", "dict")) +def test_apply_extra_script_non_string_raises(tmp_path, bad) -> None: + """A non-string extraScript fails naming the library, not with a TypeError.""" + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": bad}} + with pytest.raises(EsphomeError, match="of library owner/name must be a string"): + apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") + + +def test_extra_script_cpppath_captured_as_include_flags(tmp_path, monkeypatch): + """CPPPATH entries translate to -I flags anchored like LIBPATH.""" + + (tmp_path / "include").mkdir() + outside = tmp_path.parent / "system_inc" + outside.mkdir(exist_ok=True) + elsewhere = tmp_path.parent / "not_the_library_dir" + elsewhere.mkdir(exist_ok=True) + monkeypatch.chdir(elsewhere) + + result = ExtraScriptResult(cpppath=["include", str(outside), 7]) + flags = captured_as_build_flags(result, library_dir=tmp_path) + + assert flags == ["-Iinclude", f"-I{outside.resolve()}"] + + def test_run_extra_script_failure_discards_partial_capture(tmp_path, caplog) -> None: """A crashed script yields an empty result: half-applied flags could build wrong-output firmware that links cleanly.""" @@ -396,6 +423,6 @@ def test_uncaptured_append_key_warns_once(caplog) -> None: env = _FakeSConsEnv( board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" ) - env.Append(CPPPATH=["a"]) - env.Append(CPPPATH=["b"]) - assert caplog.text.count("env.Append(CPPPATH=...) is not captured") == 1 + env.Append(RANLIBFLAGS=["a"]) + env.Append(RANLIBFLAGS=["b"]) + assert caplog.text.count("env.Append(RANLIBFLAGS=...) is not captured") == 1