diff --git a/esphome/espidf/bootloader.py b/esphome/espidf/bootloader.py index 7ef6561f448..99a5ed15952 100644 --- a/esphome/espidf/bootloader.py +++ b/esphome/espidf/bootloader.py @@ -73,6 +73,11 @@ def _compute_enabled() -> bool: # The last configure saw a component customizing the bootloader. _LOGGER.info("Bootloader cache off: build customizes the bootloader") return False + resolved = toolchain._load_sdkconfig_json(toolchain._build_dir()) + if resolved is not None and _resolved_blocks_cache(resolved): + # Hand edits to the real sdkconfig survive snapshot preservation. + _LOGGER.info("Bootloader cache off: secure options in the tree") + return False if not os.access(tools := framework.get_idf_tools_path(), os.W_OK): # A read-only shared prefix would fail the cache on every build. _LOGGER.info("Bootloader cache off: %s is not writable", tools) @@ -120,6 +125,20 @@ def _snapshot_blocks_cache(sdkconfig_path: Path) -> bool: return config.get("CONFIG_APP_REPRODUCIBLE_BUILD", "") in _DISABLED_VALUES +def _resolved_blocks_cache(app_config: dict) -> bool: + """Secure options in the resolved config; catches hand-edited sdkconfigs. + + Key files are inputs the cache key cannot hash; *_SUPPORTED capability + constants are always on and say nothing about what is enabled. + """ + return any( + name.startswith(("SECURE_", "FLASH_ENCRYPTION")) + and not name.endswith("_SUPPORTED") + and bool(value) + for name, value in app_config.items() + ) + + def tree_uses_cached_bootloader(build_dir: Path) -> bool: """Whether a configured tree was set up for the cached bootloader.""" cmakecache = build_dir / "CMakeCache.txt" @@ -387,6 +406,10 @@ def ensure_cached_bootloader(verbose: bool = False) -> bool: _LOGGER.info("Bootloader customized by the build; building in-tree") return False app_config = toolchain._load_sdkconfig_json(toolchain._build_dir()) + if app_config is not None and _resolved_blocks_cache(app_config): + # Configure just resolved a secure option the snapshot cannot see. + _LOGGER.info("Secure options in the resolved config; building in-tree") + return False offset = _bootloader_offset(app_config) table_offset = (app_config or {}).get("PARTITION_TABLE_OFFSET") compiler = toolchain._resolved_c_compiler() diff --git a/tests/unit_tests/test_espidf_bootloader.py b/tests/unit_tests/test_espidf_bootloader.py index 2efff1215d1..9ae1cb90c47 100644 --- a/tests/unit_tests/test_espidf_bootloader.py +++ b/tests/unit_tests/test_espidf_bootloader.py @@ -125,6 +125,35 @@ def test_enabled_project_bootloader_components_disable(tmp_path: Path) -> None: assert bootloader._compute_enabled() is False +@pytest.mark.parametrize( + ("config", "blocks"), + [ + ({"SECURE_BOOT": True}, True), + ({"SECURE_BOOT_SIGNING_KEY": "keys/sb.pem"}, True), + ({"FLASH_ENCRYPTION_ENABLED": True}, True), + ({"SECURE_BOOT_V1_SUPPORTED": True, "SECURE_BOOT": False}, False), + ({"A": "1"}, False), + ], + ids=["secure-boot", "signing-key", "flash-enc", "capability-only", "plain"], +) +def test_resolved_blocks_cache(config: dict, blocks: bool) -> None: + """Key file contents are inputs the key cannot hash; capabilities are not.""" + assert bootloader._resolved_blocks_cache(config) is blocks + + +def test_enabled_resolved_secure_option_disables(tmp_path: Path) -> None: + """A menuconfig edit to the real sdkconfig never reaches the snapshot.""" + _write_snapshot("CONFIG_APP_REPRODUCIBLE_BUILD=y\n") + config_dir = toolchain._build_dir() / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "sdkconfig.json").write_text('{"SECURE_BOOT": true}') + with ( + _tools_prefix(tmp_path), + patch.object(build_gen, "idf_macro_matches", return_value=True), + ): + assert bootloader._compute_enabled() is False + + def test_enabled_recorded_bootloader_hooks_disable(tmp_path: Path) -> None: """A customization recorded by the last configure is an unseen input.""" _write_snapshot("CONFIG_APP_REPRODUCIBLE_BUILD=y\n") @@ -572,6 +601,20 @@ def test_ensure_rechecks_recorded_hooks_after_configure(tmp_path: Path) -> None: mock_build.assert_not_called() +def test_ensure_bypasses_a_resolved_secure_option(tmp_path: Path) -> None: + """Configure resolves hand-edited secure options the snapshot cannot see.""" + with ( + _orchestration_env(tmp_path), + patch.object(bootloader, "_build_standalone") as mock_build, + ): + build = CORE.relative_build_path("build") + (build / "config" / "sdkconfig.json").write_text( + '{"SECURE_BOOT_SIGNING_KEY": "sb.pem"}' + ) + assert bootloader.ensure_cached_bootloader() is False + mock_build.assert_not_called() + + def test_ensure_fails_soft_without_a_hooks_record(tmp_path: Path) -> None: """A configure that wrote no record cannot prove the bootloader is stock.""" with (