Pin PlatformIO packages and toolchains, rebase protected SDK overrides, and add WebSocket receive regression coverage. Document isolated candidate validation, archive provenance, and remaining gates.
140 lines
6.8 KiB
Python
140 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify official candidate archives; optionally fetch or prepare an isolated smoke project."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import platform
|
|
import tarfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
BASE = ROOT / ".pio/idf-candidate-5.5.3"
|
|
LOCK = pathlib.Path(__file__).with_name("artifacts.json")
|
|
|
|
|
|
class RegistryRedirects(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
check_url(newurl)
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def check_url(url):
|
|
parsed = urllib.parse.urlsplit(url)
|
|
if parsed.scheme != "https" or parsed.hostname not in {
|
|
"dl.registry.platformio.org", "dl.registry.nm1.platformio.org",
|
|
}:
|
|
raise ValueError("Unapproved artifact host: " + url)
|
|
|
|
|
|
def verify(path, item):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
if path.stat().st_size != item["size"] or digest.hexdigest() != item["checksum"]["sha256"]:
|
|
raise ValueError("Artifact size/hash mismatch: " + str(path))
|
|
|
|
|
|
def member(archive, name):
|
|
matches = [m for m in archive.getmembers() if m.name.removeprefix("./") == name and m.isfile()]
|
|
if len(matches) != 1:
|
|
raise ValueError("Missing/ambiguous archive member: " + name)
|
|
return archive.extractfile(matches[0]).read()
|
|
|
|
|
|
def require(condition):
|
|
if not condition:
|
|
raise ValueError("Candidate package contract mismatch")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--fetch", action="store_true", help="Download absent pinned archives; never install shared packages")
|
|
parser.add_argument("--prepare", action="store_true", help="Create a fresh, separate smoke project after verifying all four archives")
|
|
parser.add_argument("--sdk-only", action="store_true", help="Verify/download platform and SDK only, not toolchains; cannot prepare")
|
|
args = parser.parse_args()
|
|
if args.prepare and args.sdk_only:
|
|
parser.error("--prepare requires both toolchain archives")
|
|
if platform.system() != "Linux" or platform.machine() != "x86_64":
|
|
parser.error("This lock is for Linux x86_64 only")
|
|
# Do not follow a user-created candidate/cache symlink outside the workspace.
|
|
for path in (ROOT / ".pio", BASE, BASE / "archives"):
|
|
if path.is_symlink():
|
|
raise ValueError("Refusing symlink: " + str(path))
|
|
path.mkdir(exist_ok=True)
|
|
items = json.loads(LOCK.read_text())["artifacts"]
|
|
selected = items[:2] if args.sdk_only else items
|
|
opener = urllib.request.build_opener(RegistryRedirects())
|
|
for item in selected:
|
|
path = BASE / "archives" / item["name"]
|
|
if path.is_symlink():
|
|
raise ValueError("Refusing symlink: " + str(path))
|
|
if not path.exists() and args.fetch:
|
|
check_url(item["download_url"])
|
|
temporary = path.with_suffix(".partial")
|
|
with temporary.open("xb") as target:
|
|
try:
|
|
with opener.open(item["download_url"], timeout=60) as source:
|
|
total = 0
|
|
while block := source.read(1024 * 1024):
|
|
total += len(block)
|
|
if total > item["size"]:
|
|
raise ValueError("Download exceeds pinned size")
|
|
target.write(block)
|
|
except BaseException:
|
|
temporary.unlink()
|
|
raise
|
|
try:
|
|
verify(temporary, item)
|
|
temporary.rename(path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
verify(path, item)
|
|
print("SHA256 PASS", item["name"], flush=True)
|
|
with tarfile.open(BASE / "archives" / items[0]["name"]) as archive:
|
|
manifest = json.loads(member(archive, "platform.json"))
|
|
require(manifest["version"] == "6.13.0")
|
|
require(manifest["packages"]["framework-espidf"]["version"] == "~3.50503.0")
|
|
require(manifest["packages"]["toolchain-xtensa-esp-elf"]["version"] == "14.2.0+20251107")
|
|
with tarfile.open(BASE / "archives" / items[1]["name"]) as archive:
|
|
require(json.loads(member(archive, "package.json"))["version"] == "3.50503.0")
|
|
tools = json.loads(member(archive, "tools/tools.json"))
|
|
xtensa = next(t for t in tools["tools"] if t["name"] == "xtensa-esp-elf")
|
|
require(any(v["name"] == "esp-14.2.0_20251107" and v["status"] == "recommended" for v in xtensa["versions"]))
|
|
header = member(archive, "components/wpa_supplicant/esp_supplicant/src/esp_wifi_driver.h").decode()
|
|
require("(*wpa_ap_get_wpa_ie)(size_t *len)" in header)
|
|
expected = {
|
|
"core": "9f7b14a8bf6eec64973da8adc65d35b5ba9bee49",
|
|
"espnow": "132b4f67e339ca2081d2add91c14eefa39476ff9",
|
|
"mesh": "2e9dc1c8c7afbf033337b4175032e9b1161e3262",
|
|
"net80211": "2800d447ec385d33869373696ba8191292647694",
|
|
"pp": "8944bcad7371621045f376cf74c62fde6f368cbb",
|
|
"smartconfig": "4dc759e25617aa00b9e12887fd092a1d5780a170",
|
|
"wapi": "65655b6feab0572a6e8a1200946d53a21f3f4722",
|
|
}
|
|
for name, digest in expected.items():
|
|
data = member(archive, "components/esp_wifi/lib/esp32s3/lib" + name + ".a")
|
|
require(hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest() == digest)
|
|
print("Official manifest/toolchain, fixed callback ABI and all seven release Wi-Fi archives PASS")
|
|
if args.prepare:
|
|
project = BASE / "smoke"
|
|
project.mkdir(exist_ok=False)
|
|
(project / "src").mkdir()
|
|
(project / "src/main.c").write_text("void app_main(void) {}\n")
|
|
(project / "src/CMakeLists.txt").write_text('idf_component_register(SRCS "main.c" INCLUDE_DIRS ".")\n')
|
|
(project / "CMakeLists.txt").write_text('cmake_minimum_required(VERSION 3.16)\ninclude($ENV{IDF_PATH}/tools/cmake/project.cmake)\nproject(idf_candidate_smoke)\n')
|
|
config = "[platformio]\ncore_dir = " + str(BASE / "core") + "\n\n[env:candidate]\n"
|
|
config += "platform = " + (BASE / "archives" / items[0]["name"]).as_uri().replace("%2B", "+").replace("%", "%%") + "\n"
|
|
config += "board = esp32-s3-devkitc-1\nframework = espidf\nplatform_packages =\n"
|
|
for item in items[1:]:
|
|
config += " " + item["package"].split("/")[-1] + " @ " + (BASE / "archives" / item["name"]).as_uri().replace("%2B", "+").replace("%", "%%") + "\n"
|
|
(project / "platformio.ini").write_text(config)
|
|
print("Prepared", project)
|
|
print("Smoke only: not production sources, board customization, managed dependencies or security overrides.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|