diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 1dd5eb9aba..8dc1a6c19e 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1246,7 +1246,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: flags_id, ), ) - except OSError as err: + except (OSError, UnicodeError) as err: # Identity unknown: a stale cache entry must never be served _LOGGER.warning( "Could not establish the pch identity; compiling without it: %s", err diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 778e647a5b..4e74333f19 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -170,7 +170,7 @@ def pch_checksum( digest.update(closure[name]) digest.update(b"\0") for item in extra: - digest.update(item.encode()) + digest.update(item.encode(errors="surrogateescape")) digest.update(b"\0") return digest.hexdigest() @@ -317,7 +317,7 @@ def prepare_pch( cmd_id, ), ) - except OSError as err: + except (OSError, UnicodeError) as err: # Identity unknown: a stale cache entry must never be served _LOGGER.warning( "Could not establish the pch identity; compiling without it: %s", err diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index a09d1ebc64..38596333c4 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -158,20 +158,25 @@ def _setup_pch() -> None: # -include entries, so an unguarded user header folded here would be # included twice. An unfolded header simply stays consumer-only and # ccache hashes it directly off the command line. - folded = [name for name in include_headers if (src_dir / name).is_file()] + folded = [ + name + for name in include_headers + # An absolute name would sneak past src_dir /: keep it consumer-only + if not Path(name).is_absolute() and (src_dir / name).is_file() + ] if unfolded := [n for n in include_headers if n not in folded]: print(f"ESPHome: not precompiling non-src force-includes: {unfolded}") content = "".join(f'#include "{name}"\n' for name in (*folded, _CORE_HEADER)) digest = hashlib.sha256() - digest.update(content.encode()) - digest.update(cxx.encode()) + digest.update(content.encode(errors="surrogateescape")) + digest.update(cxx.encode(errors="surrogateescape")) # Mirror CCACHE_BASEDIR: strip the per-device build path so identical # configs produce identical .sum files and share cache entries flags_id = " ".join(flags) if basedir := os.environ.get("CCACHE_BASEDIR"): flags_id = flags_id.replace(basedir, "") - digest.update(flags_id.encode()) + digest.update(flags_id.encode(errors="surrogateescape")) # GCC never validates a .gch against its source headers, and PlatformIO # package paths carry no version, so a package bump must invalidate here platform = env.PioPlatform() # noqa: F821 diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index a7c3c5b5dd..ec81226e4d 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -175,3 +175,9 @@ def test_include_closure_survives_non_utf8_include_name(tmp_path: Path) -> None: (tmp_path / "b.h").write_text("") closure = pch._include_closure(tmp_path, ["a.h"]) assert set(closure) == {"a.h", "b.h"} + + +def test_pch_checksum_survives_surrogate_extra(tmp_path: Path) -> None: + """Install paths from non-UTF-8 filesystems carry surrogates; hashing + them must not raise past the caller's identity-unknown guard.""" + assert pch.pch_checksum(tmp_path, [], ["/opt/bad\udcff/framework"]) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index e9aafd3951..510fcbce97 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -175,6 +175,18 @@ def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None: assert pch.splitlines()[0] == '#include "other.h"' +def test_pch_script_leaves_absolute_force_includes_unfolded( + tmp_path: Path, +) -> None: + """An absolute -include resolves through src_dir / name; it must still + stay consumer-only or the host path enters the .sum.""" + outside = tmp_path / "outside.h" + outside.write_text("") + _run_script(tmp_path, flags=["-DX=1", "-include", str(outside)]) + pch = (tmp_path / "dev" / "esphome_pch.h").read_text() + assert "outside.h" not in pch + + def test_pch_script_leaves_non_src_force_includes_unfolded( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: