Files
ESP32_Serial_Swiss_Army_Knife/tests/sdk_security_overrides/run.py
T
Commander1024 797d2681ac 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.
2026-09-18 14:23:13 +02:00

460 lines
28 KiB
Python

#!/usr/bin/env python3
"""Read installed SDK sources; compile extracted patched functions with host doubles.
No dependency writes or network. --build-dir additionally verifies a real IDF
build's Ninja source registration; it does not run a firmware build.
"""
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import argparse
from dataclasses import replace
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SPEC = importlib.util.spec_from_file_location("security_overrides", ROOT / "tools/security_overrides.py")
sdk = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = sdk
SPEC.loader.exec_module(sdk)
TLS_ENTRY = next(e for e in sdk.ENTRIES if e.name == "esp_tls_mbedtls")
SOURCE_ENTRIES = tuple(e for e in sdk.ENTRIES if not e.header)
def generated_path(binary, entry):
base = binary / "security_overrides"
return (base / "wolfssh_include/wolfssh" / Path(entry.source).name if entry.header
else base / entry.name / Path(entry.source).name)
def source_path(entry, idf, project=ROOT):
return {"idf": idf, "project": project}[entry.root] / entry.source
AUXILIARY = {
"components/esp_http_server/src/httpd_main.c": "55fccf1ec01265dd9be45609c4d8eeb5d9f80da99fee23b2309a6b372c56e24f",
"components/esp_http_server/src/httpd_txrx.c": "f0978034ae0acc7e5f5c52fcb4aabc424afd403d433a417e04ff70319a7dd140",
}
FEATURES = ["MBEDTLS_SSL_PROTO_TLS1_2", "MBEDTLS_SSL_SRV_C",
"MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED", "MBEDTLS_ECDH_C",
"MBEDTLS_ECDSA_C", "MBEDTLS_AES_C", "MBEDTLS_GCM_C",
"MBEDTLS_SHA256_C", "MBEDTLS_SHA384_C"]
def run(command, *, ok=True, cwd=None):
env = dict(os.environ, CCACHE_DISABLE="1", PYTHONDONTWRITEBYTECODE="1",
TMPDIR=str(ROOT / ".pio"))
result = subprocess.run([str(x) for x in command], cwd=cwd, env=env,
capture_output=True, text=True, timeout=60)
if (result.returncode == 0) != ok:
raise AssertionError(f"command: {command}\n{result.stdout}\n{result.stderr}")
return result.stdout + result.stderr
def extract(text, name):
matches = list(re.finditer(r"^[A-Za-z_][\w* \t]*\b" + re.escape(name) + r"\([^;]*?\)\s*\{", text, re.M))
assert len(matches) == 1, (name, len(matches))
start = matches[0].start()
brace = matches[0].end() - 1
depth = 0
tokens = re.finditer(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', text[brace:], re.S)
for token in tokens:
if token.group() == "{": depth += 1
elif token.group() == "}":
depth -= 1
if depth == 0: return text[start:brace + token.end()] + "\n"
raise AssertionError(name)
def typedef(text, name):
match = re.search(r"typedef struct " + name + r"(?:_t)? \{.*?\} " + name + r"_t;", text, re.S)
assert match, name
return match.group() + "\n"
def expect_error(function, phrase):
try:
function()
except (sdk.OverrideError, OSError) as error:
assert phrase in str(error), str(error)
else:
raise AssertionError("expected rejection: " + phrase)
def generator_tests(idf, work):
binary = work / "generated"
manifest = sdk.generate(idf, ROOT, binary)
before = {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in binary.rglob("*") if p.is_file()}
assert sdk.generate(idf, ROOT, binary) == manifest
assert before == {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in before}
for entry in sdk.ENTRIES:
original = source_path(entry, idf).read_bytes()
derived = generated_path(binary, entry).read_bytes()
notice = sdk.MODIFICATION_NOTICE.encode()
assert derived.startswith(notice)
assert derived.count(notice) == 1
assert b"Modified by the ESP32_serial_swiss_army_knife project on 2026-09-15" in notice
assert original[:original.index(b"*/") + 2] in derived[:2500]
if entry.component == "wolfssl__wolfssh":
assert b"Ordering profile modified 2026-09-16" in derived[:1000]
assert derived != original
expect_error(lambda: sdk.render_entry(replace(sdk.ENTRIES[0], target="mbedtls"),
{"idf": idf, "project": ROOT}), "invalid nested target")
expect_error(lambda: sdk.render_entry(replace(sdk.ENTRIES[1], target="unknown"),
{"idf": idf, "project": ROOT}), "invalid nested target")
expect_error(lambda: sdk.render_entry(replace(sdk.ENTRIES[0], header=True),
{"idf": idf, "project": ROOT}), "unaudited header overlay")
project_copy = work / "header_mismatch"
for entry in sdk.ENTRIES:
if entry.root == "project":
copied = project_copy / entry.source
copied.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / entry.source, copied)
header = next(e for e in sdk.ENTRIES if e.header)
(project_copy / header.source).write_bytes(b"changed header")
header_failed = work / "header_failed"
expect_error(lambda: sdk.generate(idf, project_copy, header_failed), "SHA256 mismatch")
assert not header_failed.exists(), "header drift must reject the entire source/ABI plan"
expect_error(lambda: sdk.apply_edits("x", (sdk.Edit("missing", "z"),)), "got 0")
expect_error(lambda: sdk.apply_edits("xx", (sdk.Edit("x", "z"),)), "got 2")
expect_error(lambda: sdk.generate(idf, ROOT, binary, ()), "absent")
expect_error(lambda: sdk.generate(idf, ROOT, binary, (sdk.ENTRIES[0],) * 2), "duplicate")
expect_error(lambda: sdk.generate(idf, ROOT, binary,
(sdk.ENTRIES[0], replace(sdk.ENTRIES[0], name="alias"))), "ambiguous")
expect_error(lambda: sdk.generate(idf, ROOT, idf / "forbidden"), "separate")
fake = work / "sdk"
version = Path("components/esp_common/include/esp_idf_version.h")
for rel in [version] + [Path(e.source) for e in sdk.ENTRIES if e.root == "idf"]:
target = fake / rel; target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(idf / rel, target)
last = fake / TLS_ENTRY.source
last.write_bytes(last.read_bytes() + b"\n/* changed dependency */\n")
failed = work / "failed"
expect_error(lambda: sdk.generate(fake, ROOT, failed), "SHA256 mismatch")
assert not failed.exists(), "must validate all inputs before output"
# A failed regeneration must not silently update even the first derived file.
expect_error(lambda: sdk.generate(fake, ROOT, binary), "SHA256 mismatch")
assert before == {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in before}
shutil.copyfile(idf / TLS_ENTRY.source, last)
(fake / version).write_text((fake / version).read_text().replace("VERSION_PATCH 3", "VERSION_PATCH 1"))
expect_error(lambda: sdk.generate(fake, ROOT, failed), "5.5.3")
shutil.copyfile(idf / version, fake / version)
last.unlink()
expect_error(lambda: sdk.generate(fake, ROOT, failed), "No such file")
escaped = work / "escaped_output"; escaped.mkdir()
(escaped / "security_overrides").symlink_to(fake, target_is_directory=True)
expect_error(lambda: sdk.generate(idf, ROOT, escaped), "output escapes")
print("Generator exact hashes/version/absent/ambiguous/atomic-plan/idempotence/path safety PASS")
return binary
def extracted_tests(idf, binary, work):
texts = {e.name: generated_path(binary, e).read_text() for e in sdk.ENTRIES}
aux = {}
for rel, expected in AUXILIARY.items():
raw = (idf / rel).read_bytes()
assert hashlib.sha256(raw).hexdigest() == expected, rel
aux[Path(rel).name] = raw.decode()
https = texts["https_server"]
functions = typedef(https, "httpd_ssl_ctx") + typedef(https, "httpd_ssl_transport_ctx")
functions += extract(aux["httpd_main.c"], "httpd_stop")
for name in ("security_override_wipe", "httpd_ssl_close", "httpd_ssl_open",
"free_secure_context", "create_secure_context", "httpd_ssl_start", "httpd_ssl_stop"):
functions += extract(https, name)
source = (HERE / "https.c").read_text().replace("/* SDK_FUNCTIONS */", functions)
compile_run("https", source, work)
scratch = texts["httpd_parse"]
functions = "".join(extract(aux["httpd_txrx.c"], name) for name in ("httpd_recv_pending", "httpd_unrecv"))
functions += "".join(extract(scratch, name) for name in ("security_override_wipe", "security_override_resize_scratch", "read_block", "httpd_req_cleanup"))
compile_run("scratch", (HERE / "scratch.c").read_text().replace("/* SDK_FUNCTIONS */", functions), work)
tls = texts["esp_tls_mbedtls"]
original = (idf / TLS_ENTRY.source).read_text()
assert extract(tls, "set_client_config") == extract(original, "set_client_config")
assert extract(tls, "esp_create_mbedtls_handle") == extract(original, "esp_create_mbedtls_handle")
guards = tls[tls.index("/* The server profile"):tls.index('static const char *TAG = "esp-tls-mbedtls";')]
functions = extract(tls, "set_server_config") + extract(tls, "set_client_config")
source = (HERE / "tls.c").read_text().replace("/* SDK_FUNCTIONS */", functions)
source = source.replace("/* SDK_PKI */", typedef(tls, "esp_tls_pki")).replace("/* TLS_GUARDS */", guards)
defines = ["-D" + f for f in FEATURES]
compile_run("tls", source, work, defines + ["-DMBEDTLS_SSL_RENEGOTIATION", "-DCONFIG_MBEDTLS_SSL_RENEGOTIATION"])
compile_run("tls_no_renegotiation", source, work, defines)
# Compile actual injected guards independently of the behavioral doubles.
guard_file = work / "guards.c"; guard_file.write_text(guards)
for feature in FEATURES:
run(["cc", "-E", "-x", "c", *["-D" + f for f in FEATURES if f != feature], guard_file], ok=False)
run(["cc", "-E", "-x", "c", *defines, "-DCONFIG_MBEDTLS_DYNAMIC_BUFFER", guard_file], ok=False)
print("TLS feature guard matrix (each required feature + dynamic buffer rejection) PASS")
dhcp = texts["dhcpserver"]
parser = extract(dhcp, "parse_options")
upstream = parser.replace("parse_options(", "upstream_parse_options(", 1)
upstream = upstream.replace("end - optptr < 2", "optptr + 1 >= end")
upstream = upstream.replace("opt_len > end - optptr - 2", "optptr + 2 + opt_len > end")
names = ("DHCP_OPTION_PAD", "DHCP_OPTION_END", "DHCP_OPTION_MSG_TYPE",
"DHCP_OPTION_REQ_IPADDR", "DHCPDISCOVER", "DHCPREQUEST", "DHCPDECLINE", "DHCPRELEASE")
defines = "\n".join(re.search(r"^#define " + name + r"\s+[^\n]+", dhcp, re.M).group() for name in names)
source = (HERE / "dhcp.c").read_text().replace("/* SDK_DEFINES */", defines)
compile_run("dhcp", source.replace("/* SDK_FUNCTIONS */", parser + upstream), work,
["-fsanitize=undefined", "-fsanitize-undefined-trap-on-error"])
compile_run("ems", (HERE / "ems.c").read_text().replace("/* SDK_FUNCTIONS */",
extract(texts["mbedtls_ssl_tls"], "ssl_compute_master")), work,
["-fsanitize=undefined", "-fsanitize-undefined-trap-on-error"])
x509 = texts["mbedtls_x509_create"]
functions = x509[x509.index("typedef struct {"):x509.index("int mbedtls_x509_string_to_names(")]
functions += extract(x509, "mbedtls_x509_string_to_names")
compile_run("x509", (HERE / "x509.c").read_text().replace("/* SDK_FUNCTIONS */", functions), work,
["-I", str(idf / "components/mbedtls/mbedtls/include"),
"-fsanitize=undefined", "-fsanitize-undefined-trap-on-error"])
def websocket_tests(idf, binary, work):
entry = next(e for e in sdk.ENTRIES if e.name == "httpd_ws")
text = generated_path(binary, entry).read_text()
assert text.encode() == sdk.render_entry(entry, {"idf": idf, "project": ROOT})[1]
txrx = (idf / "components/esp_http_server/src/httpd_txrx.c").read_text()
assert hashlib.sha256(txrx.encode()).hexdigest() == AUXILIARY["components/esp_http_server/src/httpd_txrx.c"]
private = (idf / "components/esp_http_server/src/esp_httpd_priv.h").read_text()
options = re.search(r"typedef enum \{[^}]*\} httpd_recv_opt_t;", private)
assert options
functions = "".join(extract(txrx, n) for n in ("httpd_recv_pending", "httpd_recv_with_opt"))
functions += "".join(extract(text, n) for n in (
"httpd_ws_check_req", "httpd_ws_unmask_payload", "httpd_ws_recv_frame",
"httpd_ws_send_frame", "httpd_ws_get_frame_type"))
template = (HERE / "ws.c").read_text().replace("/* SDK_OPTIONS */", options.group())
source = template.replace("/* SDK_FUNCTIONS */", functions)
flags = ["-fsanitize=undefined", "-fsanitize-undefined-trap-on-error"]
compile_run("ws", source, work, flags)
# Independently enumerate all five sites, rather than trusting the edit registry.
sites = list(re.finditer(r"httpd_recv_with_opt\([^\n]+HTTPD_RECV_OPT_BLOCKING\) < \(int\)sizeof\(([^)]+)\)", functions))
assert [m[1] for m in sites] == ["second_byte", "length_bytes", "length_bytes", "aux->mask_key", "first_byte"]
for i, match in enumerate(sites):
removed = functions[:match.start()] + match[0].replace("(int)sizeof", "sizeof") + functions[match.end():]
c = work / f"ws_removed_cast_{i}.c"
c.write_text(template.replace("/* SDK_FUNCTIONS */", removed))
output = run(["cc", "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-fsyntax-only", c], ok=False)
assert "sign-compare" in output, output
# Explicit unsigned conversion reproduces the vendor's implicit promotion
# without disabling sign-compare diagnostics: rejection must be behavioral.
mutant = functions[:match.start()] + "(size_t)" + match[0].replace("(int)sizeof", "sizeof") + functions[match.end():]
c = work / f"ws_mutant_{i}.c"
exe = work / f"ws_mutant_{i}"
c.write_text(template.replace("/* SDK_FUNCTIONS */", mutant))
run(["cc", "-std=gnu11", "-O2", "-Wall", "-Wextra", "-Werror", *flags, c, "-o", exe])
output = run([exe], ok=False)
assert "Assertion" in output or "assertion" in output, output
print("WS five removed casts rejected by strict compilation and five unsigned-comparison behavioral mutations rejected PASS")
def compile_run(name, source, work, flags=()):
c = work / (name + ".c"); exe = work / name
c.write_text("/* Extracted SDK sections retain their upstream Apache-2.0 license. */\n" + source)
run(["cc", "-std=gnu11", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-Wno-unused-function", "-Wno-unused-variable", *flags, "-I", HERE, c, "-o", exe])
print(run([exe]).strip())
def cmake_fixture_tests(idf, work):
# Use real component inputs with mock IDF target discovery. No SDK compilation.
fixture = work / "cmake_fixture"; fixture.mkdir()
installed_idf = idf
idf = fixture / "idf"
for rel in ["components/esp_common/include/esp_idf_version.h"] + [e.source for e in sdk.ENTRIES if e.root == "idf"]:
dest = idf / rel; dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(installed_idf / rel, dest)
nested_dir = idf / "components/mbedtls/mbedtls/library"
nested_lines = []
lines = ["cmake_minimum_required(VERSION 3.18)", "project(security_fixture C)",
'set(CMAKE_EXPORT_COMPILE_COMMANDS ON)',
f'set(TEST_IDF "{idf}")',
'function(idf_build_get_property out property)',
' set(${out} "${TEST_IDF}" PARENT_SCOPE)', 'endfunction()',
'function(idf_component_get_property out component property)',
' set(${out} "test_${component}" PARENT_SCOPE)', 'endfunction()']
targets = set()
for e in sdk.ENTRIES:
if e.root == "project":
copied = fixture / e.source
copied.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source_path(e, idf), copied)
if e.header:
continue
target = e.target or f"test_{e.component}"
owner_lines = nested_lines if e.target else lines
registered_source = Path(e.source).name if e.target else source_path(e, idf, fixture)
if target not in targets:
owner_lines += [f'add_library({target} STATIC "{registered_source}")']
targets.add(target)
else:
owner_lines += [f'target_sources({target} PRIVATE "{source_path(e, idf, fixture)}")']
lines += ['add_library(test_mbedtls INTERFACE)', f'add_subdirectory("{nested_dir}" nested)']
(nested_dir / "CMakeLists.txt").write_text(
'if(NOT TEST_NESTED_MISSING AND NOT TEST_NESTED_OWNER)\n' + "\n".join(nested_lines) + '\nendif()\n')
lines += ['if(TEST_NESTED_OWNER)',
f'add_library(mbedtls STATIC "{source_path(sdk.ENTRIES[1], idf)}")', 'endif()']
for target in ("mbedtls", "mbedx509"):
entry = next(e for e in sdk.ENTRIES if e.target == target)
lines += [f'if(TEST_{target}_MISSING_SOURCE)', f'set_property(TARGET {target} PROPERTY SOURCES missing.c)', 'endif()',
f'if(TEST_{target}_DUPLICATE)', f'set_property(TARGET {target} APPEND PROPERTY SOURCES "{source_path(entry, idf)}")', 'endif()']
lines += ['if(TEST_MISSING)', f'set_property(TARGET test_{sdk.ENTRIES[0].component} PROPERTY SOURCES missing.c)', 'endif()',
'if(TEST_AMBIGUOUS)', f'set_property(TARGET test_{sdk.ENTRIES[0].component} APPEND PROPERTY SOURCES "{source_path(sdk.ENTRIES[0], idf, fixture)}")', 'endif()',
'if(TEST_TARGET_MISSING)', 'function(idf_component_get_property out component property)',
'set(${out} nonexistent PARENT_SCOPE)', 'endfunction()', 'endif()']
for e in SOURCE_ENTRIES:
directory = f' DIRECTORY "{nested_dir}"' if e.target else ''
lines += [f'set_source_files_properties("{source_path(e, idf, fixture)}"{directory} PROPERTIES COMPILE_FLAGS "-DSOURCE_FLAG" COMPILE_DEFINITIONS "SOURCE_DEFINE" COMPILE_OPTIONS "-fno-common")']
(fixture / 'consumer.c').write_text('int consumer(void) { return 0; }\n')
lines += ['add_library(direct_consumer STATIC consumer.c)',
'target_link_libraries(direct_consumer PUBLIC test_wolfssl__wolfssh)',
'add_library(transitive_consumer STATIC consumer.c)',
'target_link_libraries(transitive_consumer PRIVATE direct_consumer)',
f'include("{ROOT / "cmake/security_overrides.cmake"}")']
for e in SOURCE_ENTRIES:
target = e.target or f"test_{e.component}"
directory = f' DIRECTORY "{nested_dir}"' if e.target else ''
lines += [f'file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/{e.name}.sources" CONTENT "$<TARGET_PROPERTY:{target},SOURCES>")',
f'get_property(flags SOURCE "${{SAK_SECURITY_{e.name}_GENERATED}}"{directory} PROPERTY COMPILE_FLAGS)',
'if(NOT flags STREQUAL "-DSOURCE_FLAG")', 'message(FATAL_ERROR "lost compile flags")', 'endif()',
f'get_property(inc SOURCE "${{SAK_SECURITY_{e.name}_GENERATED}}"{directory} PROPERTY INCLUDE_DIRECTORIES)',
f'if(NOT inc MATCHES "{source_path(e, idf, fixture).parent}")', 'message(FATAL_ERROR "lost original quoted include directory")', 'endif()']
(fixture / "CMakeLists.txt").write_text("\n".join(lines) + "\n")
build = work / "cmake_good"
run(["cmake", "-G", "Ninja", "-S", fixture, "-B", build])
for e in SOURCE_ENTRIES:
source = (build / (e.name + ".sources")).read_text()
assert source.split(';').count(str(build / "security_overrides" / e.name / Path(e.source).name)) == 1
assert str(source_path(e, idf, fixture)) not in source
build_registration(build, idf, fixture)
commands = json.loads((build / "compile_commands.json").read_text())
for e in SOURCE_ENTRIES:
generated = str(generated_path(build, e))
matches = [c for c in commands if c["file"] == generated]
assert len(matches) == 1, (e.name, matches)
for option in ("-DSOURCE_FLAG", "-DSOURCE_DEFINE", "-fno-common",
str(source_path(e, idf, fixture).parent)):
assert option in matches[0]["command"], (e.name, option, matches)
header = next(e for e in sdk.ENTRIES if e.header)
overlay = str(generated_path(build, header))
consumers = [c for c in commands if c['file'].endswith('/consumer.c') or
'/wolfssh_internal/' in c['file'] or '/wolfssh_ssh/' in c['file']]
assert len(consumers) == 4
for command in consumers:
assert '-include' + overlay in command['command']
assert str(build / 'security_overrides/wolfssh_include') in command['command']
print('PUBLIC forced header/overlay reaches library, direct and transitive consumers PASS')
ninja = (build / "build.ninja").read_text()
for path in [ROOT / "tools/wolfssh_order/delta.json", ROOT / "tools/security_overrides.py", idf / "components/esp_common/include/esp_idf_version.h"] + [source_path(e, idf, fixture) for e in sdk.ENTRIES]:
assert str(path) in next(line for line in ninja.splitlines() if ": RERUN_CMAKE" in line), path
for flag, phrase in (("TEST_MISSING", "found 0"), ("TEST_AMBIGUOUS", "found 2"),
("TEST_TARGET_MISSING", "missing wolfSSH overlay target"),
("TEST_NESTED_MISSING", "missing nested target"),
("TEST_NESTED_OWNER", "unexpected nested target owner"),
("TEST_mbedtls_MISSING_SOURCE", "found 0"),
("TEST_mbedtls_DUPLICATE", "found 2"),
("TEST_mbedx509_MISSING_SOURCE", "found 0"),
("TEST_mbedx509_DUPLICATE", "found 2")):
output = run(["cmake", "-G", "Ninja", "-S", fixture, "-B", work / flag, "-D" + flag + "=ON"], ok=False)
assert phrase in output, output
print("CMake actual include: exact target replacement/properties/reconfigure/fail-closed matrix PASS")
def extension_fixture_tests(idf, work):
# Prove the extension API, relative SOURCES in a child directory, real quoted
# includes, target/source flags, and automatic fail-closed reconfiguration.
fixture = work / "extension"
for directory in ("cmake", "tools", "component/src", "component/include"):
(fixture / directory).mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / "cmake/security_overrides.cmake", fixture / "cmake/security_overrides.cmake")
c = fixture / "component/src/example.c"
c.write_text('#include "local.h"\n#include "extra.h"\n'
'#if !defined(SOURCE_FLAG) || !defined(SOURCE_DEFINE) || !defined(SOURCE_OPTION) || !defined(TARGET_DEFINE)\n'
'#error "compile properties were lost"\n#endif\n'
'int example(void) { return LOCAL + EXTRA + 1; }\n')
original = c.read_bytes(); digest = hashlib.sha256(original).hexdigest()
(c.parent / "local.h").write_text("#define LOCAL 10\n")
(fixture / "component/include/extra.h").write_text("#define EXTRA 20\n")
(fixture / "component/CMakeLists.txt").write_text('add_library(test_extension STATIC src/example.c)\n'
'target_compile_definitions(test_extension PRIVATE TARGET_DEFINE)\n'
'set_source_files_properties(src/example.c PROPERTIES COMPILE_FLAGS "-DSOURCE_FLAG" '
'COMPILE_OPTIONS "-DSOURCE_OPTION" COMPILE_DEFINITIONS "SOURCE_DEFINE" '
'COMPILE_DEFINITIONS_DEBUG "CONFIG_DEFINE" INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/include")\n')
(fixture / "main.c").write_text('int example(void); int main(void) { return example() != 32; }\n')
wrapper = ('import sys\nfrom pathlib import Path\nsys.dont_write_bytecode = True\n'
f'sys.path.insert(0, {str(ROOT / "tools")!r})\nimport security_overrides as sdk\n'
'import argparse\np=argparse.ArgumentParser()\n'
'[p.add_argument(a, type=Path, required=True) for a in ("--idf-path", "--project-dir", "--binary-dir")]\n'
'a=p.parse_args()\n'
f'e=sdk.Entry("extension", "extension", "project", "component/src/example.c", {digest!r}, '
'(sdk.Edit("LOCAL + EXTRA + 1", "LOCAL + EXTRA + 2"),))\n'
'sdk.generate(a.idf_path, a.project_dir, a.binary_dir, (e,))\n')
(fixture / "tools/security_overrides.py").write_text(wrapper)
(fixture / "CMakeLists.txt").write_text('cmake_minimum_required(VERSION 3.18)\nproject(extension C)\n'
f'set(TEST_IDF "{idf}")\n'
'function(idf_build_get_property out property)\nset(${out} "${TEST_IDF}" PARENT_SCOPE)\nendfunction()\n'
'function(idf_component_get_property out component property)\nset(${out} "test_${component}" PARENT_SCOPE)\nendfunction()\n'
'add_subdirectory(component)\ninclude(cmake/security_overrides.cmake)\n'
'get_property(config_def SOURCE "${SAK_SECURITY_extension_GENERATED}" DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/component" PROPERTY COMPILE_DEFINITIONS_DEBUG)\n'
'if(NOT config_def STREQUAL "CONFIG_DEFINE")\nmessage(FATAL_ERROR "lost per-config source definitions")\nendif()\n'
'add_executable(check main.c)\ntarget_link_libraries(check PRIVATE test_extension)\n')
build = work / "extension_build"
run(["cmake", "-G", "Ninja", "-S", fixture, "-B", build])
run(["cmake", "--build", build]); run([build / "check"])
assert c.read_bytes() == original
generated = build / "security_overrides/extension/example.c"
stamp = generated.stat().st_mtime_ns
run(["cmake", "--build", build]); assert generated.stat().st_mtime_ns == stamp
c.write_bytes(original + b"\n/* upstream changed */\n")
output = run(["cmake", "--build", build], ok=False)
assert "SHA256 mismatch" in output, output
assert generated.stat().st_mtime_ns == stamp
print("Extension mapping + child relative source/includes/flags real compile + automatic mismatch rejection PASS")
def build_registration(build, idf, project=ROOT):
ninja = (build / "build.ninja").read_text()
compile_lines = [line for line in ninja.splitlines() if ": C_COMPILER" in line]
for e in sdk.ENTRIES:
generated = generated_path(build, e)
if e.header:
assert not any(str(generated) in line for line in compile_lines)
assert generated.read_bytes() == sdk.render_entry(e, {"idf": idf, "project": project})[1]
continue
matches = [line for line in compile_lines if str(generated) in line]
assert len(matches) == 1, (e.name, matches)
assert not any(str(source_path(e, idf, project)) in line for line in compile_lines), e.name
target = e.target or (f"test_{e.component}" if project != ROOT else f"__idf_{e.component}")
assert f"CMakeFiles/{target}.dir/" in matches[0], (e.name, matches)
assert generated.read_bytes() == sdk.render_entry(e, {"idf": idf, "project": project})[1]
kind = "Real IDF" if project == ROOT else "CMake fixture"
print(f"{kind} Ninja registration: generated inputs once on exact owner, originals absent, bytes verified PASS")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, default=Path.home() / ".platformio/packages/framework-espidf")
parser.add_argument("--build-dir", type=Path)
args = parser.parse_args()
idf = args.idf_path.resolve()
sdk.verify_version(idf)
(ROOT / ".pio").mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="sdk-security-", dir=ROOT / ".pio") as tmp:
work = Path(tmp)
binary = generator_tests(idf, work)
extracted_tests(idf, binary, work)
websocket_tests(idf, binary, work)
cmake_fixture_tests(idf, work)
extension_fixture_tests(idf, work)
if args.build_dir: build_registration(args.build_dir.resolve(), idf)
print("SDK security overrides: all requested host checks PASS (not live TLS/hardware)")
if __name__ == "__main__":
main()