From 4d92741e692925f910aa4103a45721e2c054edd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 16:01:06 -0500 Subject: [PATCH] Pin the decoy anchor, guard unselected segments, fingerprint the module source --- esphome/components/esp8266/boards.py | 37 +++++++++---------- esphome/components/esp8266/build_surgery.py | 25 ++++++++----- .../components/esp8266/test_build_surgery.py | 25 ++++++++----- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 2646682766..4f137e95cd 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -361,31 +361,30 @@ BOARDS = { }, } -""" -ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the -native toolchain mirrors; regenerate against the tag when bumping it): - -git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 -python3 - <<'EOF' -import json, glob, os -for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): - b = json.load(open(f))["build"] - extra = b["extra_flags"] - extra = extra.split() if isinstance(extra, str) else extra - defines = [ - e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") - ] - entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") - board = os.path.splitext(os.path.basename(f))[0] - print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') -EOF -""" # Per-board Arduino core build metadata for the native (PlatformIO-free) # toolchain: the variant directory (supplies pins_arduino.h) and the # board-identity defines the PlatformIO builder passes via build.extra_flags. # -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by # the generator; only the per-board defines are listed here. +# +# ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the +# native toolchain mirrors; regenerate against the tag when bumping it): +# +# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 +# python3 - <<'EOF' +# import json, glob, os +# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): +# b = json.load(open(f))["build"] +# extra = b["extra_flags"] +# extra = extra.split() if isinstance(extra, str) else extra +# defines = [ +# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") +# ] +# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") +# board = os.path.splitext(os.path.basename(f))[0] +# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +# EOF ESP8266_BOARD_BUILD = { "agruminolemon": { "variant": "agruminolemonv4", diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 47f9042f08..97ce750dd5 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -82,6 +82,14 @@ def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str raises, since a silently kept real memory limit would fail grouped builds far from the cause. """ + for segment in _TESTING_SEGMENT_SIZES: + if segment not in segments and _segment_line_re(segment).search(content): + # A known segment left unpatched would keep its real memory limit + # and silently under-provision the testing build + raise RuntimeError( + f"Testing-mode segment {segment} is present in the linker " + "script but was not selected for patching" + ) for segment in segments: if segment not in _TESTING_SEGMENT_SIZES: raise RuntimeError(f"Unknown testing-mode segment {segment!r}") @@ -103,15 +111,14 @@ def segment_length(content: str, segment_name: str) -> int | None: def surgery_fingerprint() -> str: - """Fingerprint of every behavioral input to the surgeries. + """Fingerprint of this module's source, covering every behavioral input. - Linker-script caches include it so an edit here invalidates them. + Linker-script caches include it so an edit here invalidates them; hashing + the source over-invalidates on comment edits, which is the safe direction. Native-toolchain-only, like ``segment_length``; no script twin. """ - parts = ( - RATETABLE_RULE, - _RATETABLE_COMMENT, - _RATETABLE_ANCHOR.pattern, - repr(sorted(_TESTING_SEGMENT_SIZES.items())), - ) - return hashlib.sha256("|".join(parts).encode()).hexdigest() + import inspect + import sys + + source = inspect.getsource(sys.modules[__name__]) + return hashlib.sha256(source.encode()).hexdigest() diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index e62a2270fb..944518b1fa 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -49,7 +49,8 @@ def test_relocate_ratetable_inserts_after_data_start() -> None: patched = relocate_ratetable(_COMMON_LD_SNIPPET) assert RATETABLE_RULE in patched # Inserted after the .data section's anchor, not the .dport0.data one - assert patched.index("_data_start = ABSOLUTE(.);") < patched.index(RATETABLE_RULE) + # (whose closing brace bounds the decoy block) + assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")] assert patched.index(RATETABLE_RULE) < patched.index("*(.data)") # Idempotent on an already-patched script assert relocate_ratetable(patched) == patched @@ -106,14 +107,20 @@ def test_board_build_covers_every_board() -> None: assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) -def test_surgery_fingerprint_tracks_inputs() -> None: - """The fingerprint changes with any behavioral input, so linker-script - caches stamped with it self-invalidate on surgery edits.""" - from unittest.mock import patch +def test_surgery_fingerprint_covers_module_source() -> None: + """The fingerprint hashes the module source, so any surgery edit + invalidates linker-script caches stamped with it.""" + import hashlib + import inspect from esphome.components.esp8266 import build_surgery - base = build_surgery.surgery_fingerprint() - assert base == build_surgery.surgery_fingerprint() - with patch.object(build_surgery, "_TESTING_SEGMENT_SIZES", {"iram1_0_seg": "0x1"}): - assert build_surgery.surgery_fingerprint() != base + expected = hashlib.sha256(inspect.getsource(build_surgery).encode()).hexdigest() + assert build_surgery.surgery_fingerprint() == expected + + +def test_testing_memory_patches_present_but_unselected_raises() -> None: + """A known segment left off the caller's list must fail, not silently + keep its real memory limit.""" + with pytest.raises(RuntimeError, match="not selected"): + apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))