Skip to content

Mechanism to error out on removed configuration options #164

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions scripts/mbedtls_framework/config_checks_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Generate C preprocessor code to check for bad configurations.
"""

## Copyright The Mbed TLS Contributors
## SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later

import argparse
import os
import sys
import typing
from typing import Dict

from . import build_tree
from . import typing_util


class BranchData(typing.NamedTuple):
"""The relevant aspects of the configuration on a branch."""

# Subdirectory where the generated headers will be located.
header_directory: str

# Prefix used to the generated headers' basename.
header_prefix: str

# Prefix used for C preprocessor macros.
project_cpp_prefix: str

# removed_options[option_name] = replacement_description
# replacement_description is a short string intended for human consumption.
removed_options: Dict[str, str]


class HeaderGenerator:
"""Common code for BeforeHeaderGenerator and AfterHeaderGenerator."""

def __init__(self, branch_data) -> None:
self.branch_data = branch_data
self.prefix = branch_data.project_cpp_prefix + '_CONFIG_CHECK_'
self.remember_defined_prefix = self.prefix + 'defined_'
self.dead_option_macro = self.prefix + 'option_is_no_longer_supported'
self.allow_removed = self.prefix + 'ALLOW_REMOVED_OPTIONS'

def write_stanza(self, out: typing_util.Writable, option: str) -> None:
"""Write the part of the output corresponding to one config option."""
raise NotImplementedError

def write_content(self, out: typing_util.Writable) -> None:
"""Write the output for all config options to be processed."""
for option in sorted(self.branch_data.removed_options.keys()):
self.write_stanza(out, option)

def write(self, filename: str) -> None:
"""Write the whole output file."""
with open(filename, 'w') as out:
out.write(f"""\
/* {os.path.basename(filename)}: checks before including the user configuration file. */
/* Automatically generated by {os.path.basename(sys.argv[0])}. Do not edit! */

#if !defined({self.allow_removed})

#define {self.dead_option_macro} 0xdeadc0f1

/* *INDENT-OFF* */
""")
self.write_content(out)
out.write(f"""
/* *INDENT-ON* */

#endif /* !defined({self.allow_removed}) */

/* End of automatically generated {os.path.basename(filename)} */
""")


class BeforeHeaderGenerator(HeaderGenerator):
"""Generate a header to include immediately before the user configuration."""

def write_stanza(self, out: typing_util.Writable, option: str) -> None:
was_defined = self.remember_defined_prefix + option
out.write(f"""
#if defined({option})
# define {was_defined} 1
# undef {option}
#else
# define {was_defined} 0
#endif
#define {option} {self.dead_option_macro}
""")


class AfterHeaderGenerator(HeaderGenerator):
"""Generate a header to include immediately after the user configuration."""

def write_stanza(self, out: typing_util.Writable, option: str) -> None:
was_defined = self.remember_defined_prefix + option
suggestion = self.branch_data.removed_options[option]
out.write(f"""
#if !defined({option})
# error "Undefining {option} is no longer supported. Suggestion: {suggestion}"
#elif ({option} + 0) != {self.dead_option_macro}
# error "Defining {option} is no longer supported. Suggestion: {suggestion}"
#endif
#undef {option}
#if {was_defined}
# define {option}
#endif
#undef {was_defined}
""")


def generate_header_files(root: str, branch_data: BranchData) -> None:
"""Generate the header files to include before and after *config.h."""
before_generator = BeforeHeaderGenerator(branch_data)
before_generator.write(os.path.join(root,
branch_data.header_directory,
branch_data.header_prefix +
'config_check_before.h'))
after_generator = AfterHeaderGenerator(branch_data)
after_generator.write(os.path.join(root,
branch_data.header_directory,
branch_data.header_prefix +
'config_check_after.h'))


def main(branch_data: BranchData) -> None:
parser = argparse.ArgumentParser(description=__doc__)
_options = parser.parse_args()
root = build_tree.guess_project_root()
generate_header_files(root, branch_data)
199 changes: 199 additions & 0 deletions scripts/mbedtls_framework/test_config_checks_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Test the configuration checks generated by generate_config_checks.py.
"""

## Copyright The Mbed TLS Contributors
## SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later

import glob
import os
import re
import subprocess
import sys
import tempfile
import unittest
from typing import List, Optional, Pattern, Union

from . import config_checks_generator


class TestConfigChecks(unittest.TestCase):
"""Unit tests for checks generated by config_checks_generator."""

# Set this to the path to the source file containing the config checks.
PROJECT_CONFIG_C = None #type: Optional[str]

# Increase the length of strings that assertion failures are willing to
# print. This is useful for failures where the preprocessor has a lot
# to say.
maxDiff = 9999

def user_config_file_name(self, variant: str) -> str:
"""Construct a unique temporary file name for a user config header."""
name = os.path.splitext(os.path.basename(sys.argv[0]))[0]
pid = str(os.getpid())
oid = str(id(self))
return f'tmp-user_config_{variant}-{name}-{pid}-{oid}.h'

def write_user_config(self, variant: str, content: Optional[str]) -> Optional[str]:
"""Write a user configuration file with the given content.

If content is None, ensure the file does not exist.

Return None if content is none, otherwise return the file name.
"""
file_name = self.user_config_file_name(variant)
if content is None:
if os.path.exists(file_name):
os.remove(file_name)
return None
if content and not content.endswith('\n'):
content += '\n'
with open(file_name, 'w', encoding='ascii') as out:
out.write(content)
return file_name

def run_with_config_files(self,
crypto_user_config_file: Optional[str],
mbedtls_user_config_file: Optional[str],
extra_options: List[str],
) -> subprocess.CompletedProcess:
"""Run cpp with the given user configuration files.

Return the CompletedProcess object capturing the return code,
stdout and stderr.
"""
cmd = ['cpp']
if crypto_user_config_file is not None:
cmd.append(f'-DTF_PSA_CRYPTO_USER_CONFIG_FILE="{crypto_user_config_file}"')
if mbedtls_user_config_file is not None:
cmd.append(f'-DMBEDTLS_USER_CONFIG_FILE="{mbedtls_user_config_file}"')
cmd += extra_options
assert self.PROJECT_CONFIG_C is not None
cmd += ['-Iinclude', '-Idrivers/builtin/include', '-I.',
'-I' + os.path.dirname(self.PROJECT_CONFIG_C),
self.PROJECT_CONFIG_C]
return subprocess.run(cmd,
check=False,
encoding='ascii',
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)

def run_with_config(self,
crypto_user_config: Optional[str],
mbedtls_user_config: Optional[str] = None,
extra_options: Optional[List[str]] = None,
) -> subprocess.CompletedProcess:
"""Run cpp with the given content for user configuration files.

Return the CompletedProcess object capturing the return code,
stdout and stderr.
"""
if extra_options is None:
extra_options = []
crypto_user_config_file = None
mbedtls_user_config_file = None
try:
# Create temporary files without using tempfile because:
# 1. Before Python 3.12, tempfile.NamedTemporaryFile does
# not have good support for allowing an external program
# to access the file on Windows.
# 2. With a tempfile-provided context, it's awkward to not
# create a file optionally (we only do it when xxx_user_config
# is not None).
crypto_user_config_file = \
self.write_user_config('crypto', crypto_user_config)
mbedtls_user_config_file = \
self.write_user_config('mbedtls', mbedtls_user_config)
cp = self.run_with_config_files(crypto_user_config_file,
mbedtls_user_config_file,
extra_options)
return cp
finally:
if crypto_user_config_file is not None and \
os.path.exists(crypto_user_config_file):
os.remove(crypto_user_config_file)
if mbedtls_user_config_file is not None and \
os.path.exists(mbedtls_user_config_file):
os.remove(mbedtls_user_config_file)

def good_case(self,
crypto_user_config: Optional[str],
mbedtls_user_config: Optional[str] = None,
extra_options: Optional[List[str]] = None,
) -> None:
"""Run cpp with the given user config(s). Expect no error.

Pass extra_options on the command line of cpp.
"""
cp = self.run_with_config(crypto_user_config, mbedtls_user_config,
extra_options=extra_options)
self.assertEqual(cp.stderr, '')
self.assertEqual(cp.returncode, 0)

def bad_case(self,
crypto_user_config: Optional[str],
mbedtls_user_config: Optional[str] = None,
error: Optional[Union[str, Pattern]] = None,
extra_options: Optional[List[str]] = None,
) -> None:
"""Run cpp with the given user config(s). Expect errors.

Pass extra_options on the command line of cpp.

If error is given, the standard error from cpp must match this regex.
"""
cp = self.run_with_config(crypto_user_config, mbedtls_user_config,
extra_options=extra_options)
if error is not None:
self.assertRegex(cp.stderr, error)
self.assertGreater(cp.returncode, 0)
self.assertLess(cp.returncode, 126)

def test_nominal(self) -> None:
self.good_case(None)

def test_error(self) -> None:
self.bad_case('#error "Bad crypto configuration"',
error='"Bad crypto configuration"')

@staticmethod
def normalize_generated_file_content(content) -> None:
"""Normalize the content of a generated file.

The file content is mostly deterministic, but it includes the
name of the program that generated it. That name is different
when we generate it from the test code, so we erase the program
name from the content.
"""
return re.sub(r'([Gg]enerated by )\S+(\.py\W*\s)',
r'\1normalized\2',
content)

def check_generated_file(self,
production_directory: str,
new_file: str) -> None:
"""Check whether a generated file is up to date."""
production_file = os.path.join(production_directory,
os.path.basename(new_file))
self.assertTrue(os.path.exists(production_file))
with open(new_file) as inp:
new_content = inp.read()
with open(production_file) as inp:
production_content = inp.read()
# The second line contains "generated by test_script.py" here
# but "generated by production_script.py" in the production
# file. Everything else should be identical.
self.assertEqual(
self.normalize_generated_file_content(new_content),
self.normalize_generated_file_content(production_content))

def up_to_date_case(self,
production_data: config_checks_generator.BranchData
) -> None:
"""Check whether the generated files are up to date."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_data = production_data._replace(header_directory=temp_dir)
config_checks_generator.generate_header_files(os.curdir, temp_data)
for new_file in glob.glob(temp_dir + '/*'):
self.check_generated_file(production_data.header_directory,
new_file)