Migrate to IDF 5.5.3 candidate
Pin PlatformIO packages and toolchains, rebase protected SDK overrides, and add WebSocket receive regression coverage. Document isolated candidate validation, archive provenance, and remaining gates.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"review_date": "2026-09-16",
|
||||
"host": "linux_x86_64",
|
||||
"artifacts": [
|
||||
{
|
||||
"package": "platformio/platform/espressif32",
|
||||
"version": "6.13.0",
|
||||
"registry_url": "https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32",
|
||||
"name": "espressif32-6.13.0.tar.gz",
|
||||
"size": 1009115,
|
||||
"checksum": {
|
||||
"sha256": "5d1032b43828773ba87cf2e509432202c0bfe64f7304b58c9d669f13b116c6e0"
|
||||
},
|
||||
"system": "*",
|
||||
"download_url": "https://dl.registry.platformio.org/download/platformio/platform/espressif32/6.13.0/espressif32-6.13.0.tar.gz"
|
||||
},
|
||||
{
|
||||
"package": "platformio/tool/framework-espidf",
|
||||
"version": "3.50503.0",
|
||||
"registry_url": "https://api.registry.platformio.org/v3/packages/platformio/tool/framework-espidf",
|
||||
"name": "framework-espidf-3.50503.0.tar.gz",
|
||||
"size": 76402966,
|
||||
"checksum": {
|
||||
"sha256": "8353f6fd5030dd7e662500891428fad15d46efd7e4b718cab2fe6bfb9e7f13fc"
|
||||
},
|
||||
"system": "*",
|
||||
"download_url": "https://dl.registry.platformio.org/download/platformio/tool/framework-espidf/3.50503.0/framework-espidf-3.50503.0.tar.gz"
|
||||
},
|
||||
{
|
||||
"package": "platformio/tool/toolchain-xtensa-esp-elf",
|
||||
"version": "14.2.0+20251107",
|
||||
"registry_url": "https://api.registry.platformio.org/v3/packages/platformio/tool/toolchain-xtensa-esp-elf",
|
||||
"name": "toolchain-xtensa-esp-elf-linux_x86_64-14.2.0+20251107.tar.gz",
|
||||
"size": 322439270,
|
||||
"checksum": {
|
||||
"sha256": "a5de49ce3299b0d9253ab6a423648bc23113db96b34a7cc8e57702cae1bb190e"
|
||||
},
|
||||
"system": [
|
||||
"linux_x86_64"
|
||||
],
|
||||
"download_url": "https://dl.registry.platformio.org/download/platformio/tool/toolchain-xtensa-esp-elf/14.2.0+20251107/toolchain-xtensa-esp-elf-linux_x86_64-14.2.0+20251107.tar.gz"
|
||||
},
|
||||
{
|
||||
"package": "platformio/tool/toolchain-riscv32-esp",
|
||||
"version": "14.2.0+20251107",
|
||||
"registry_url": "https://api.registry.platformio.org/v3/packages/platformio/tool/toolchain-riscv32-esp",
|
||||
"name": "toolchain-riscv32-esp-linux_x86_64-14.2.0+20251107.tar.gz",
|
||||
"size": 592894688,
|
||||
"checksum": {
|
||||
"sha256": "1af8e233931500b8712079808e4974413d95d3601d03275dff79665c436e9d33"
|
||||
},
|
||||
"system": [
|
||||
"linux_x86_64"
|
||||
],
|
||||
"download_url": "https://dl.registry.platformio.org/download/platformio/tool/toolchain-riscv32-esp/14.2.0+20251107/toolchain-riscv32-esp-linux_x86_64-14.2.0+20251107.tar.gz"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage current tracked working-tree inputs plus explicitly reviewed test inputs."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import subprocess
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
BASE = ROOT / ".pio/idf-candidate-5.5.3"
|
||||
EXACT = {"CMakeLists.txt", "platformio.ini", "extra_script.py", "partitions.csv",
|
||||
"sdkconfig.defaults", "dependencies.lock", "tools/security_overrides.py"}
|
||||
PREFIXES = ("src/", "cmake/", "boards/", "tests/", "tools/wolfssh_order/")
|
||||
UNTRACKED = {"tests/sdk_security_overrides/ws.c"}
|
||||
|
||||
|
||||
def stage(destination, root=ROOT, base=BASE, items=None):
|
||||
# A single fresh child prevents traversal into archives/core or old app outputs.
|
||||
if destination in {"", ".", ".."} or pathlib.Path(destination).name != destination:
|
||||
raise ValueError("Destination must be a single fresh candidate directory name")
|
||||
dest = base / destination
|
||||
for path in (root / ".pio", base, dest):
|
||||
if path.is_symlink():
|
||||
raise ValueError("Refusing symlink destination: " + str(path))
|
||||
if base.resolve() != root.resolve() / ".pio/idf-candidate-5.5.3":
|
||||
raise ValueError("Candidate root is not confined to the project")
|
||||
if dest.exists():
|
||||
raise FileExistsError(dest)
|
||||
if items is None:
|
||||
items = json.loads(pathlib.Path(__file__).with_name("artifacts.json").read_text())["artifacts"]
|
||||
tracked = subprocess.check_output(["git", "--no-pager", "ls-files", "-z"], cwd=root).decode().split("\0")
|
||||
names = {name for name in tracked if name in EXACT or name.startswith(PREFIXES)}
|
||||
names.update(name for name in UNTRACKED if (root / name).exists())
|
||||
inputs = {}
|
||||
for name in sorted(names):
|
||||
source = root / name
|
||||
if source.is_symlink() or source.resolve() != root.resolve() / name:
|
||||
raise ValueError("Refusing symlink source: " + name)
|
||||
inputs[name] = source.read_bytes()
|
||||
config = inputs["platformio.ini"].decode()
|
||||
replacements = {"[platformio]\n": "[platformio]\ncore_dir = " + str(base / "core") + "\n"}
|
||||
for index, item in enumerate(items):
|
||||
package = "platformio/" + item["package"].split("/")[-1]
|
||||
uri = (base / "archives" / item["name"]).as_uri().replace("%2B", "+").replace("%", "%%")
|
||||
replacements[package + "@" + item["version"]] = uri if index == 0 else package.split("/")[-1] + " @ " + uri
|
||||
for old, new in replacements.items():
|
||||
if config.count(old) != 1:
|
||||
raise ValueError("Missing/ambiguous platform configuration anchor: " + old)
|
||||
config = config.replace(old, new, 1)
|
||||
inputs["platformio.ini"] = config.encode()
|
||||
dest.mkdir(exist_ok=False)
|
||||
manifest = {}
|
||||
for name, data in inputs.items():
|
||||
target = dest / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
manifest[name] = hashlib.sha256(data).hexdigest()
|
||||
(dest / "app-source-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print("Staged", len(manifest), "working-tree inputs in", dest)
|
||||
print("No existing sdkconfig, secrets, .git, cache or managed components copied.")
|
||||
print("Copy the six hash-verified locked managed-component directories separately before building.")
|
||||
return dest
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--destination", required=True, help="Fresh direct child name under .pio/idf-candidate-5.5.3 (e.g. app-validated)")
|
||||
args = parser.parse_args()
|
||||
stage(args.destination)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Offline fail-closed helper checks; does not download/install/build."""
|
||||
import hashlib
|
||||
import io
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import stage_app
|
||||
import prepare
|
||||
|
||||
|
||||
class Contracts(unittest.TestCase):
|
||||
def test_hash_and_size(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "artifact"
|
||||
path.write_bytes(b"candidate")
|
||||
item = {"size": 9, "checksum": {"sha256": hashlib.sha256(b"candidate").hexdigest()}}
|
||||
prepare.verify(path, item)
|
||||
path.write_bytes(b"Candidate")
|
||||
with self.assertRaises(ValueError):
|
||||
prepare.verify(path, item)
|
||||
path.write_bytes(b"candidate-extra")
|
||||
with self.assertRaises(ValueError):
|
||||
prepare.verify(path, item)
|
||||
|
||||
def test_network_allowlist(self):
|
||||
prepare.check_url("https://dl.registry.platformio.org/download/a")
|
||||
prepare.check_url("https://dl.registry.nm1.platformio.org/download/a")
|
||||
for url in ("http://dl.registry.platformio.org/a", "https://example.com/a", "https://dl.registry.platformio.org.evil/a"):
|
||||
with self.assertRaises(ValueError):
|
||||
prepare.check_url(url)
|
||||
|
||||
def test_contract_failure(self):
|
||||
with self.assertRaises(ValueError):
|
||||
prepare.require(False)
|
||||
|
||||
def test_member_ambiguity(self):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w") as archive:
|
||||
for name in ("package.json", "./package.json"):
|
||||
entry = tarfile.TarInfo(name)
|
||||
entry.size = 2
|
||||
archive.addfile(entry, io.BytesIO(b"{}"))
|
||||
stream.seek(0)
|
||||
with tarfile.open(fileobj=stream) as archive:
|
||||
with self.assertRaises(ValueError):
|
||||
prepare.member(archive, "package.json")
|
||||
with self.assertRaises(ValueError):
|
||||
prepare.member(archive, "missing")
|
||||
|
||||
|
||||
class Staging(unittest.TestCase):
|
||||
def test_fresh_snapshot_and_manifest(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = pathlib.Path(directory)
|
||||
base = root / ".pio/idf-candidate-5.5.3"
|
||||
base.mkdir(parents=True)
|
||||
(base / "app").mkdir()
|
||||
(base / "app/sentinel").write_text("old")
|
||||
(root / "platformio.ini").write_text("[platformio]\nplatform = platformio/espressif32@6.13.0\n")
|
||||
(root / "src").mkdir()
|
||||
(root / "src/main.c").write_text("modified working tree")
|
||||
(root / "src/secret").write_text("never copy")
|
||||
ws = root / "tests/sdk_security_overrides/ws.c"
|
||||
ws.parent.mkdir(parents=True)
|
||||
ws.write_text("explicit fixture")
|
||||
items = [{"package": "platformio/platform/espressif32", "version": "6.13.0", "name": "platform.tar.gz"}]
|
||||
with mock.patch.object(stage_app.subprocess, "check_output", return_value=b"platformio.ini\0src/main.c\0"):
|
||||
dest = stage_app.stage("app-validated", root, base, items)
|
||||
manifest = json.loads((dest / "app-source-manifest.json").read_text())
|
||||
for name, digest in manifest.items():
|
||||
self.assertEqual(hashlib.sha256((dest / name).read_bytes()).hexdigest(), digest)
|
||||
self.assertIn(str(base / "core"), (dest / "platformio.ini").read_text())
|
||||
self.assertEqual((dest / "src/main.c").read_text(), "modified working tree")
|
||||
self.assertTrue((dest / "tests/sdk_security_overrides/ws.c").exists())
|
||||
self.assertFalse((dest / "src/secret").exists())
|
||||
self.assertEqual((base / "app/sentinel").read_text(), "old")
|
||||
for name in ("app", "app-validated"):
|
||||
with self.assertRaises(FileExistsError):
|
||||
stage_app.stage(name, root, base, items)
|
||||
for name in ("../escape", "/tmp/escape", "", ".", "..", "app/nested"):
|
||||
with self.assertRaises(ValueError):
|
||||
stage_app.stage(name, root, base, items)
|
||||
(base / "linked").symlink_to(root, target_is_directory=True)
|
||||
with self.assertRaises(ValueError):
|
||||
stage_app.stage("linked", root, base, items)
|
||||
(root / "src/main.c").unlink()
|
||||
(root / "src/main.c").symlink_to(root / "platformio.ini")
|
||||
with self.assertRaises(ValueError):
|
||||
stage_app.stage("unsafe", root, base, items)
|
||||
self.assertFalse((base / "unsafe").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -461,13 +461,13 @@ ENTRIES = (
|
||||
)),
|
||||
Entry("mbedtls_ssl_tls", "mbedtls", "idf",
|
||||
"components/mbedtls/mbedtls/library/ssl_tls.c",
|
||||
"b726c0c55bc5f32255f129d55f9f2fface85ce83de90a2d16c9017b93b738bff", (
|
||||
"0154e70f20b465213d3f97a9c2b75e686d79ee83592f90e1d7e7d995593ca7e1", (
|
||||
Edit(' MBEDTLS_SSL_DEBUG_RET(1, "calc_verify", ret);\n',
|
||||
' MBEDTLS_SSL_DEBUG_RET(1, "calc_verify", ret);\n return ret;\n'),
|
||||
), target="mbedtls"),
|
||||
Entry("mbedtls_x509_create", "mbedtls", "idf",
|
||||
"components/mbedtls/mbedtls/library/x509_create.c",
|
||||
"fd399239aee30384786a19b47bfe5dd22b979d5d89bb38f29f0c82a3d81daaf7", (
|
||||
"55edce5b8fcb039a404b84ea830f06d018128b155dfbf0d74d26b17247b546c6", (
|
||||
Edit(" oid.p = mbedtls_calloc(1, oid.len);\n",
|
||||
""" oid.p = mbedtls_calloc(1, oid.len);
|
||||
if (oid.p == NULL) {
|
||||
@@ -553,7 +553,7 @@ ENTRIES = (
|
||||
)),
|
||||
Entry("https_server", "esp_https_server", "idf",
|
||||
"components/esp_https_server/src/https_server.c",
|
||||
"6481942b62e51125e2a43441fa0900cbda74bd2ea05c82f0c29eb4933c31946e", (
|
||||
"a2a5ca0549fbe8d1ddd7f9647a48a31fdd6329b997e1550ab69d6a10efecff0d", (
|
||||
Edit('const static char *TAG = "esp_https_server";\n',
|
||||
WIPE + 'const static char *TAG = "esp_https_server";\n'),
|
||||
Edit(""" if (!transport_ctx) {
|
||||
@@ -598,7 +598,7 @@ ENTRIES = (
|
||||
)),
|
||||
Entry("httpd_parse", "esp_http_server", "idf",
|
||||
"components/esp_http_server/src/httpd_parse.c",
|
||||
"6bba77064aaa68a06f8d4c01432064a1b050c91ed22741c547785b0d8a6c07d8", (
|
||||
"db7fbbb322bccb4a21bc1607208a6bb7d1f4c0d7ecc3299de10c9e60edaa943b", (
|
||||
Edit('static const char *TAG = "httpd_parse";\n',
|
||||
WIPE + SCRATCH_RESIZE + 'static const char *TAG = "httpd_parse";\n'),
|
||||
Edit(" size_t at_offset = parser_data->last.at - raux->scratch;\n",
|
||||
@@ -623,12 +623,25 @@ ENTRIES = (
|
||||
)),
|
||||
Entry("esp_tls_mbedtls", "esp-tls", "idf",
|
||||
"components/esp-tls/esp_tls_mbedtls.c",
|
||||
"09210c5a601647ca5775d127a2951bab2f3e509192b53487bbea8a93d8731b78", (
|
||||
"edc39052244526cb91c93a16bc765194031060e5560fd432e75542f2f6c8db53", (
|
||||
Edit('static const char *TAG = "esp-tls-mbedtls";\n',
|
||||
TLS_GUARDS + 'static const char *TAG = "esp-tls-mbedtls";\n'),
|
||||
Edit(" mbedtls_ssl_conf_set_user_data_p(&tls->conf, cfg->userdata);\n",
|
||||
TLS_POLICY + " mbedtls_ssl_conf_set_user_data_p(&tls->conf, cfg->userdata);\n"),
|
||||
)),
|
||||
# 5.5.3 introduced blocking header reads, but sizeof promotes negative
|
||||
# receive errors to unsigned. These fixed header extents are at most 8 bytes.
|
||||
Entry("httpd_ws", "esp_http_server", "idf",
|
||||
"components/esp_http_server/src/httpd_ws.c",
|
||||
"a02194bc8adb1a1707f680390f30ccfab95cd83230cbe4aedcadda2999c9649c",
|
||||
tuple(Edit(f"HTTPD_RECV_OPT_BLOCKING) < sizeof({name}))",
|
||||
f"HTTPD_RECV_OPT_BLOCKING) < (int)sizeof({name}))")
|
||||
for name in ("second_byte", "aux->mask_key", "first_byte")) +
|
||||
tuple(Edit(f'HTTPD_RECV_OPT_BLOCKING) < sizeof(length_bytes)) {{\n'
|
||||
f' ESP_LOGW(TAG, LOG_FMT("Failed to receive {size} bytes length"));',
|
||||
f'HTTPD_RECV_OPT_BLOCKING) < (int)sizeof(length_bytes)) {{\n'
|
||||
f' ESP_LOGW(TAG, LOG_FMT("Failed to receive {size} bytes length"));')
|
||||
for size in (2, 8))),
|
||||
)
|
||||
|
||||
|
||||
@@ -656,10 +669,10 @@ def apply_edits(text: str, edits: tuple[Edit, ...]) -> str:
|
||||
def verify_version(idf: Path) -> Path:
|
||||
version = idf / "components/esp_common/include/esp_idf_version.h"
|
||||
text = version.read_text(encoding="utf-8")
|
||||
for part, expected in (("MAJOR", "5"), ("MINOR", "5"), ("PATCH", "0")):
|
||||
for part, expected in (("MAJOR", "5"), ("MINOR", "5"), ("PATCH", "3")):
|
||||
found = re.findall(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$", text, re.M)
|
||||
if found != [expected]:
|
||||
raise OverrideError(f"requires ESP-IDF 5.5.0: {version} ({part}={found})")
|
||||
raise OverrideError(f"requires ESP-IDF 5.5.3: {version} ({part}={found})")
|
||||
return version
|
||||
|
||||
|
||||
@@ -683,6 +696,10 @@ def render_entry(entry: Entry, roots: dict[str, Path]) -> tuple[Path, bytes]:
|
||||
if actual != entry.sha256:
|
||||
raise OverrideError(f"{entry.name}: SHA256 mismatch for {source}: expected {entry.sha256}, got {actual}; reaudit, do not repin blindly")
|
||||
notice = MODIFICATION_NOTICE
|
||||
if entry.root == "idf":
|
||||
notice += ("/* Rebased to audited ESP-IDF 5.5.3 originals on 2026-09-16.\n"
|
||||
" * All local corrections retained; see docs/idf_candidate_integration.md.\n"
|
||||
" */\n")
|
||||
if entry.component == "wolfssl__wolfssh":
|
||||
notice += ("/* Ordering profile modified 2026-09-16: PR793/819/840/855/921\n"
|
||||
" * plus project restricted no-EXT_INFO correction. Provenance and\n"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
@@ -31,6 +33,10 @@ def positive_seconds(value):
|
||||
|
||||
def parser():
|
||||
cli = argparse.ArgumentParser(description=__doc__)
|
||||
cli.add_argument('--build-dir', type=Path, default=BUILD, help='existing firmware build directory')
|
||||
cli.add_argument('--idf-path', type=Path, help='explicit ESP-IDF SDK for SDK-aware checks')
|
||||
cli.add_argument('--platformio-core-dir', type=Path, help='explicit isolated PlatformIO core directory')
|
||||
cli.add_argument('--web-performance', action='store_true', help='also validate generated WebSocket performance contracts')
|
||||
cli.add_argument('--build', action='store_true', help='run pio run before host checks (may fetch dependencies)')
|
||||
cli.add_argument('--interop', action='store_true', help='enable ordering OpenSSH local Unix-socket matrix')
|
||||
cli.add_argument('--dry-run', action='store_true', help='print plan only; no execution or prerequisite validation')
|
||||
@@ -50,12 +56,15 @@ def plan(options):
|
||||
commands.append(Command(label or name, (sys.executable, '-B', str(path), *map(str, args)),
|
||||
options.timeout, (path, *required)))
|
||||
|
||||
header = BUILD / 'config/sdkconfig.h'
|
||||
database = BUILD / 'compile_commands.json'
|
||||
build = options.build_dir
|
||||
idf_args = ('--idf-path', options.idf_path) if options.idf_path else ()
|
||||
header = build / 'config/sdkconfig.h'
|
||||
database = build / 'compile_commands.json'
|
||||
suite('security_build_policy', '--sdkconfig-header', header, required=(header,))
|
||||
for name in ('ssh_auth_policy', 'ssh_auth_transport', 'hidden_input', 'ssh_memory'):
|
||||
for name in ('ssh_auth_policy', 'ssh_auth_transport', 'hidden_input'):
|
||||
suite(name)
|
||||
suite('sdk_security_overrides', '--build-dir', BUILD, required=(database,))
|
||||
suite('ssh_memory', *idf_args)
|
||||
suite('sdk_security_overrides', '--build-dir', build, *idf_args, required=(database,))
|
||||
for name in ('wolfssh_auth_contract', 'ssh_protocol_policy', 'wolf_crypto_policy'):
|
||||
suite(name, '--compile-commands', database, required=(database,))
|
||||
suite('wolfssh_parser_contract')
|
||||
@@ -68,9 +77,47 @@ def plan(options):
|
||||
for mode in ('base', 'admin', 'accounts', 'ssh', 'lifecycle'):
|
||||
suite('web_cookie_auth', *([] if mode == 'base' else ['--' + mode]),
|
||||
label='web_cookie_auth:' + mode)
|
||||
if options.web_performance:
|
||||
suite('web_serial_performance', '--build-dir', build, *idf_args, required=(database,))
|
||||
return commands
|
||||
|
||||
|
||||
def verify_snapshot(root, build):
|
||||
"""Compare source inputs, not path-dependent generated build configuration."""
|
||||
entries = json.loads((build / 'compile_commands.json').read_text())
|
||||
mains = [(Path(e['directory']) / e['file']).resolve() for e in entries
|
||||
if Path(e['file']).parts[-2:] == ('src', 'main.c')]
|
||||
if len(mains) != 1:
|
||||
raise RuntimeError('expected exactly one application main.c compilation input')
|
||||
snapshot = mains[0].parents[1]
|
||||
scopes = ('src', 'cmake', 'boards', 'managed_components', 'tools/wolfssh_order',
|
||||
'tools/security_overrides.py', 'CMakeLists.txt', 'extra_script.py',
|
||||
'sdkconfig.defaults', 'partitions.csv')
|
||||
digest = hashlib.sha256()
|
||||
count = 0
|
||||
for scope in scopes:
|
||||
def files(base):
|
||||
path = base / scope
|
||||
if path.is_file():
|
||||
return {Path(scope)}
|
||||
if not path.is_dir():
|
||||
raise RuntimeError(f'missing snapshot input: {path}')
|
||||
return {p.relative_to(base) for p in path.rglob('*') if p.is_file()
|
||||
and not {'.git', '__pycache__'} & set(p.parts)}
|
||||
paths = files(root)
|
||||
if paths != files(snapshot):
|
||||
raise RuntimeError(f'root/build snapshot file set differs: {scope}')
|
||||
for relative in sorted(paths):
|
||||
data = (root / relative).read_bytes()
|
||||
if data != (snapshot / relative).read_bytes():
|
||||
raise RuntimeError(f'root/build snapshot content differs: {relative}')
|
||||
digest.update(str(relative).encode() + b'\0' + hashlib.sha256(data).digest())
|
||||
count += 1
|
||||
evidence = (snapshot, count, digest.hexdigest())
|
||||
print(f'SNAPSHOT source equality: {snapshot}; {count} files; SHA256 {evidence[2]}', flush=True)
|
||||
return evidence
|
||||
|
||||
|
||||
def kill_group(process):
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
@@ -105,12 +152,16 @@ def run_command(command, root, env):
|
||||
return ('PASS', 'exit 0') if code == 0 else ('FAIL', f'exit {code}')
|
||||
|
||||
|
||||
def execute(commands, root, *, dry_run=False, fail_fast=False):
|
||||
def execute(commands, root, *, dry_run=False, fail_fast=False, idf_path=None, platformio_core_dir=None):
|
||||
if os.name != 'posix':
|
||||
print('PREREQ: POSIX process groups required', flush=True)
|
||||
return 1
|
||||
env = os.environ.copy()
|
||||
env['CCACHE_DISABLE'] = '1'
|
||||
if idf_path is not None:
|
||||
env['IDF_PATH'] = str(idf_path.resolve())
|
||||
if platformio_core_dir is not None:
|
||||
env['PLATFORMIO_CORE_DIR'] = str(platformio_core_dir.resolve())
|
||||
results = []
|
||||
stopped = False
|
||||
for command in commands:
|
||||
@@ -143,7 +194,20 @@ def main(argv=None):
|
||||
print('SKIP optional build: --build not requested; existing artifacts required', flush=True)
|
||||
if not options.interop:
|
||||
print('SKIP optional interop: --interop not requested', flush=True)
|
||||
return execute(plan(options), ROOT, dry_run=options.dry_run, fail_fast=options.fail_fast)
|
||||
if options.build and options.build_dir != BUILD:
|
||||
parser().error('--build cannot be combined with a non-default --build-dir; build it separately')
|
||||
try:
|
||||
evidence = None
|
||||
if not options.dry_run and options.build_dir != BUILD:
|
||||
evidence = verify_snapshot(ROOT, options.build_dir.resolve())
|
||||
result = execute(plan(options), ROOT, dry_run=options.dry_run, fail_fast=options.fail_fast,
|
||||
idf_path=options.idf_path, platformio_core_dir=options.platformio_core_dir)
|
||||
if evidence is not None and verify_snapshot(ROOT, options.build_dir.resolve()) != evidence:
|
||||
raise RuntimeError('snapshot changed during validation')
|
||||
return result
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
print(f'PREREQ snapshot: {error}', flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user