diff --git a/esphome/espidf/install_tool_archives.py b/esphome/espidf/install_tool_archives.py index 019aae36b5..d1a300277a 100644 --- a/esphome/espidf/install_tool_archives.py +++ b/esphome/espidf/install_tool_archives.py @@ -25,7 +25,7 @@ def collect_pending( targets_csv: str, tool_specs: list[str] ) -> tuple[dict[tuple[str, str], object], int]: """The {(name, version): tool} jobs whose verified archive is on disk, - and how many uninstalled tools were resolved overall.""" + and how many distinct uninstalled tools were resolved overall.""" dist_path = Path(g.idf_tools_path) / "dist" def on_broken(name: str, e: ToolBinaryError) -> bool: @@ -34,11 +34,14 @@ def collect_pending( return False pending: dict[tuple[str, str], object] = {} - resolved = 0 + resolved: set[tuple[str, str]] = set() for tool, name, version, download in iter_tool_downloads( targets_csv, tool_specs, on_broken ): - resolved += 1 + resolved.add((name, version)) + # Mirror the prefetch: an entry it could not verify is never trusted + if not (download.sha256 and download.size): + continue # Trusted as-is: the prefetch verifies archives at their final name, # and the installer redoes anything this pass fails on if (name, version) in pending or not ( @@ -46,7 +49,7 @@ def collect_pending( ).is_file(): continue pending[(name, version)] = tool - return pending, resolved + return pending, len(resolved) def install_one(tool: object, name: str, version: str) -> bool | None: diff --git a/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py b/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py index 28aefdd5b8..f932001eca 100644 --- a/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py +++ b/tests/unit_tests/fixtures/idf_tools_stub/idf_tools.py @@ -117,6 +117,12 @@ for _name, _tool in _TOOLS.items(): def load_tools_info() -> dict[str, _Tool]: + # Test hook: strip verification metadata from the named tools + for name in os.environ.get("TEST_NO_SHA", "").split(","): + if (tool := _TOOLS.get(name)) is not None: + for version in tool.versions.values(): + if (download := version.get_download_for_platform("")) is not None: + download.sha256 = "" return _TOOLS diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 42e9ffb81b..0d70015fcb 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2249,3 +2249,21 @@ def test_install_tool_archives_surviving_torn_dir_escalates( assert "could not remove" in err assert "1 of 2 pre-extractions failed" in err assert (tmp_path / "tp" / "tools" / "cmake" / "3.30.2" / ".installed").is_file() + + +def test_install_tool_archives_skips_unverifiable_archives( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """An entry the prefetch could not verify is never extracted, even with + an archive on disk.""" + _make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip") + monkeypatch.setenv("TEST_NO_SHA", "cmake,ninja") + _run_espidf_script_inprocess( + tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required" + ) + assert not (tmp_path / "tp" / "tools").exists() + assert "0 of 2 uninstalled tool(s) have a prefetched archive" in ( + capsys.readouterr().out + )