Apply hash-pinned generated edits for CVE-2025-14942 while keeping wolfSSH 1.4.20 managed sources unchanged. Add the ABI header overlay, provenance records, and real state-machine interoperability contracts.
421 lines
25 KiB
Python
421 lines
25 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": "a16ef65069dda13889c67b922f25eb566573983d6c24f01c089a902d5fd26149",
|
|
"components/esp_http_server/src/httpd_txrx.c": "7659ad52c32f29b9a08208dc8b22d023edf274047835ed58107d82a47ccce00e",
|
|
}
|
|
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 0", "VERSION_PATCH 1"))
|
|
expect_error(lambda: sdk.generate(fake, ROOT, failed), "5.5.0")
|
|
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 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)
|
|
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()
|