Merge pull request #4408 from gilles-peskine-arm/storage-format-check-mononicity
Check storage format tests for regressions
This commit is contained in:
commit
fdfc10b250
@ -34,7 +34,7 @@ Use a similar approach for files other than keys where possible and relevant.
|
||||
|
||||
Test cases should normally not be removed from the code base: if something has worked before, it should keep working in future versions, so we should keep testing it.
|
||||
|
||||
This cannot be enforced solely by looking at a single version of Mbed TLS, since there would be no indication that more test cases used to exist. It can only be enforced through review of library changes. The review may be assisted by a tool that compares the old and the new version, in the same way that `abi-check.py` compares the library's API and ABI.
|
||||
This cannot be enforced solely by looking at a single version of Mbed TLS, since there would be no indication that more test cases used to exist. It can only be enforced through review of library changes. The review is be assisted by a tool that compares the old and the new version, which is implemented in `scripts/abi_check.py`. This tool fails the CI if load-and-check test case disappears (changed test cases are raised as false positives).
|
||||
|
||||
If the way certain keys are stored changes, and we don't deliberately decide to stop supporting old keys (which should only be done by retiring a version of the storage format), then we should keep the corresponding test cases in load-only mode: create a file with the expected content, load it and check the data that it contains.
|
||||
|
||||
|
@ -1,14 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Purpose
|
||||
This script compares the interfaces of two versions of Mbed TLS, looking
|
||||
for backward incompatibilities between two different Git revisions within
|
||||
an Mbed TLS repository. It must be run from the root of a Git working tree.
|
||||
|
||||
This script is a small wrapper around the abi-compliance-checker and
|
||||
abi-dumper tools, applying them to compare the ABI and API of the library
|
||||
files from two different Git revisions within an Mbed TLS repository.
|
||||
The results of the comparison are either formatted as HTML and stored at
|
||||
a configurable location, or are given as a brief list of problems.
|
||||
Returns 0 on success, 1 on ABI/API non-compliance, and 2 if there is an error
|
||||
while running the script. Note: must be run from Mbed TLS root.
|
||||
For the source (API) and runtime (ABI) interface compatibility, this script
|
||||
is a small wrapper around the abi-compliance-checker and abi-dumper tools,
|
||||
applying them to compare the header and library files.
|
||||
|
||||
For the storage format, this script compares the automatically generated
|
||||
storage tests and the manual read tests, and complains if there is a
|
||||
reduction in coverage. A change in test data will be signaled as a
|
||||
coverage reduction since the old test data is no longer present. A change in
|
||||
how test data is presented will be signaled as well; this would be a false
|
||||
positive.
|
||||
|
||||
The results of the API/ABI comparison are either formatted as HTML and stored
|
||||
at a configurable location, or are given as a brief list of problems.
|
||||
Returns 0 on success, 1 on non-compliance, and 2 if there is an error
|
||||
while running the script.
|
||||
|
||||
You must run this test from an Mbed TLS root.
|
||||
"""
|
||||
|
||||
# Copyright The Mbed TLS Contributors
|
||||
@ -26,7 +38,9 @@ while running the script. Note: must be run from Mbed TLS root.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import shutil
|
||||
@ -51,6 +65,9 @@ class AbiChecker:
|
||||
configuration.report_dir: directory for output files
|
||||
configuration.keep_all_reports: if false, delete old reports
|
||||
configuration.brief: if true, output shorter report to stdout
|
||||
configuration.check_abi: if true, compare ABIs
|
||||
configuration.check_api: if true, compare APIs
|
||||
configuration.check_storage: if true, compare storage format tests
|
||||
configuration.skip_file: path to file containing symbols and types to skip
|
||||
"""
|
||||
self.repo_path = "."
|
||||
@ -64,6 +81,11 @@ class AbiChecker:
|
||||
self.old_version = old_version
|
||||
self.new_version = new_version
|
||||
self.skip_file = configuration.skip_file
|
||||
self.check_abi = configuration.check_abi
|
||||
self.check_api = configuration.check_api
|
||||
if self.check_abi != self.check_api:
|
||||
raise Exception('Checking API without ABI or vice versa is not supported')
|
||||
self.check_storage_tests = configuration.check_storage
|
||||
self.brief = configuration.brief
|
||||
self.git_command = "git"
|
||||
self.make_command = "make"
|
||||
@ -208,6 +230,93 @@ class AbiChecker:
|
||||
self.log.debug(abi_dump_output.decode("utf-8"))
|
||||
version.abi_dumps[mbed_module] = output_path
|
||||
|
||||
@staticmethod
|
||||
def _normalize_storage_test_case_data(line):
|
||||
"""Eliminate cosmetic or irrelevant details in storage format test cases."""
|
||||
line = re.sub(r'\s+', r'', line)
|
||||
return line
|
||||
|
||||
def _read_storage_tests(self,
|
||||
directory,
|
||||
filename,
|
||||
is_generated,
|
||||
storage_tests):
|
||||
"""Record storage tests from the given file.
|
||||
|
||||
Populate the storage_tests dictionary with test cases read from
|
||||
filename under directory.
|
||||
"""
|
||||
at_paragraph_start = True
|
||||
description = None
|
||||
full_path = os.path.join(directory, filename)
|
||||
with open(full_path) as fd:
|
||||
for line_number, line in enumerate(fd, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
at_paragraph_start = True
|
||||
continue
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
if at_paragraph_start:
|
||||
description = line.strip()
|
||||
at_paragraph_start = False
|
||||
continue
|
||||
if line.startswith('depends_on:'):
|
||||
continue
|
||||
# We've reached a test case data line
|
||||
test_case_data = self._normalize_storage_test_case_data(line)
|
||||
if not is_generated:
|
||||
# In manual test data, only look at read tests.
|
||||
function_name = test_case_data.split(':', 1)[0]
|
||||
if 'read' not in function_name.split('_'):
|
||||
continue
|
||||
metadata = SimpleNamespace(
|
||||
filename=filename,
|
||||
line_number=line_number,
|
||||
description=description
|
||||
)
|
||||
storage_tests[test_case_data] = metadata
|
||||
|
||||
@staticmethod
|
||||
def _list_generated_test_data_files(git_worktree_path):
|
||||
"""List the generated test data files."""
|
||||
output = subprocess.check_output(
|
||||
['tests/scripts/generate_psa_tests.py', '--list'],
|
||||
cwd=git_worktree_path,
|
||||
).decode('ascii')
|
||||
return [line for line in output.split('\n') if line]
|
||||
|
||||
def _get_storage_format_tests(self, version, git_worktree_path):
|
||||
"""Record the storage format tests for the specified git version.
|
||||
|
||||
The storage format tests are the test suite data files whose name
|
||||
contains "storage_format".
|
||||
|
||||
The version must be checked out at git_worktree_path.
|
||||
|
||||
This function creates or updates the generated data files.
|
||||
"""
|
||||
# Existing test data files. This may be missing some automatically
|
||||
# generated files if they haven't been generated yet.
|
||||
storage_data_files = set(glob.glob(
|
||||
'tests/suites/test_suite_*storage_format*.data'
|
||||
))
|
||||
# Discover and (re)generate automatically generated data files.
|
||||
to_be_generated = set()
|
||||
for filename in self._list_generated_test_data_files(git_worktree_path):
|
||||
if 'storage_format' in filename:
|
||||
storage_data_files.add(filename)
|
||||
to_be_generated.add(filename)
|
||||
subprocess.check_call(
|
||||
['tests/scripts/generate_psa_tests.py'] + sorted(to_be_generated),
|
||||
cwd=git_worktree_path,
|
||||
)
|
||||
for test_file in sorted(storage_data_files):
|
||||
self._read_storage_tests(git_worktree_path,
|
||||
test_file,
|
||||
test_file in to_be_generated,
|
||||
version.storage_tests)
|
||||
|
||||
def _cleanup_worktree(self, git_worktree_path):
|
||||
"""Remove the specified git worktree."""
|
||||
shutil.rmtree(git_worktree_path)
|
||||
@ -219,11 +328,14 @@ class AbiChecker:
|
||||
self.log.debug(worktree_output.decode("utf-8"))
|
||||
|
||||
def _get_abi_dump_for_ref(self, version):
|
||||
"""Generate the ABI dumps for the specified git revision."""
|
||||
"""Generate the interface information for the specified git revision."""
|
||||
git_worktree_path = self._get_clean_worktree_for_git_revision(version)
|
||||
self._update_git_submodules(git_worktree_path, version)
|
||||
self._build_shared_libraries(git_worktree_path, version)
|
||||
self._get_abi_dumps_from_shared_libraries(version)
|
||||
if self.check_abi:
|
||||
self._build_shared_libraries(git_worktree_path, version)
|
||||
self._get_abi_dumps_from_shared_libraries(version)
|
||||
if self.check_storage_tests:
|
||||
self._get_storage_format_tests(version, git_worktree_path)
|
||||
self._cleanup_worktree(git_worktree_path)
|
||||
|
||||
def _remove_children_with_tag(self, parent, tag):
|
||||
@ -301,6 +413,37 @@ class AbiChecker:
|
||||
os.remove(output_path)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_storage_format_compatible(old_tests, new_tests,
|
||||
compatibility_report):
|
||||
"""Check whether all tests present in old_tests are also in new_tests.
|
||||
|
||||
Append a message regarding compatibility to compatibility_report.
|
||||
"""
|
||||
missing = frozenset(old_tests.keys()).difference(new_tests.keys())
|
||||
for test_data in sorted(missing):
|
||||
metadata = old_tests[test_data]
|
||||
compatibility_report.append(
|
||||
'Test case from {} line {} "{}" has disappeared: {}'.format(
|
||||
metadata.filename, metadata.line_number,
|
||||
metadata.description, test_data
|
||||
)
|
||||
)
|
||||
compatibility_report.append(
|
||||
'FAIL: {}/{} storage format test cases have changed or disappeared.'.format(
|
||||
len(missing), len(old_tests)
|
||||
) if missing else
|
||||
'PASS: All {} storage format test cases are preserved.'.format(
|
||||
len(old_tests)
|
||||
)
|
||||
)
|
||||
compatibility_report.append(
|
||||
'Info: number of storage format tests cases: {} -> {}.'.format(
|
||||
len(old_tests), len(new_tests)
|
||||
)
|
||||
)
|
||||
return not missing
|
||||
|
||||
def get_abi_compatibility_report(self):
|
||||
"""Generate a report of the differences between the reference ABI
|
||||
and the new ABI. ABI dumps from self.old_version and self.new_version
|
||||
@ -310,12 +453,22 @@ class AbiChecker:
|
||||
self._pretty_revision(self.new_version)
|
||||
)]
|
||||
compliance_return_code = 0
|
||||
shared_modules = list(set(self.old_version.modules.keys()) &
|
||||
set(self.new_version.modules.keys()))
|
||||
for mbed_module in shared_modules:
|
||||
if not self._is_library_compatible(mbed_module,
|
||||
compatibility_report):
|
||||
|
||||
if self.check_abi:
|
||||
shared_modules = list(set(self.old_version.modules.keys()) &
|
||||
set(self.new_version.modules.keys()))
|
||||
for mbed_module in shared_modules:
|
||||
if not self._is_library_compatible(mbed_module,
|
||||
compatibility_report):
|
||||
compliance_return_code = 1
|
||||
|
||||
if self.check_storage_tests:
|
||||
if not self._is_storage_format_compatible(
|
||||
self.old_version.storage_tests,
|
||||
self.new_version.storage_tests,
|
||||
compatibility_report):
|
||||
compliance_return_code = 1
|
||||
|
||||
for version in [self.old_version, self.new_version]:
|
||||
for mbed_module, mbed_module_dump in version.abi_dumps.items():
|
||||
os.remove(mbed_module_dump)
|
||||
@ -328,7 +481,8 @@ class AbiChecker:
|
||||
"""Generate a report of ABI differences
|
||||
between self.old_rev and self.new_rev."""
|
||||
self.check_repo_path()
|
||||
self.check_abi_tools_are_installed()
|
||||
if self.check_api or self.check_abi:
|
||||
self.check_abi_tools_are_installed()
|
||||
self._get_abi_dump_for_ref(self.old_version)
|
||||
self._get_abi_dump_for_ref(self.new_version)
|
||||
return self.get_abi_compatibility_report()
|
||||
@ -337,17 +491,7 @@ class AbiChecker:
|
||||
def run_main():
|
||||
try:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"""This script is a small wrapper around the
|
||||
abi-compliance-checker and abi-dumper tools, applying them
|
||||
to compare the ABI and API of the library files from two
|
||||
different Git revisions within an Mbed TLS repository.
|
||||
The results of the comparison are either formatted as HTML and
|
||||
stored at a configurable location, or are given as a brief list
|
||||
of problems. Returns 0 on success, 1 on ABI/API non-compliance,
|
||||
and 2 if there is an error while running the script.
|
||||
Note: must be run from Mbed TLS root."""
|
||||
)
|
||||
description=__doc__
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true",
|
||||
@ -397,6 +541,24 @@ def run_main():
|
||||
"(typically \"-s identifiers\" after running "
|
||||
"\"tests/scripts/list-identifiers.sh --internal\")")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-abi",
|
||||
action='store_true', default=True,
|
||||
help="Perform ABI comparison (default: yes)"
|
||||
)
|
||||
parser.add_argument("--no-check-abi", action='store_false', dest='check_abi')
|
||||
parser.add_argument(
|
||||
"--check-api",
|
||||
action='store_true', default=True,
|
||||
help="Perform API comparison (default: yes)"
|
||||
)
|
||||
parser.add_argument("--no-check-api", action='store_false', dest='check_api')
|
||||
parser.add_argument(
|
||||
"--check-storage",
|
||||
action='store_true', default=True,
|
||||
help="Perform storage tests comparison (default: yes)"
|
||||
)
|
||||
parser.add_argument("--no-check-storage", action='store_false', dest='check_storage')
|
||||
parser.add_argument(
|
||||
"-b", "--brief", action="store_true",
|
||||
help="output only the list of issues to stdout, instead of a full report",
|
||||
@ -413,6 +575,7 @@ def run_main():
|
||||
crypto_repository=abi_args.old_crypto_repo,
|
||||
crypto_revision=abi_args.old_crypto_rev,
|
||||
abi_dumps={},
|
||||
storage_tests={},
|
||||
modules={}
|
||||
)
|
||||
new_version = SimpleNamespace(
|
||||
@ -423,6 +586,7 @@ def run_main():
|
||||
crypto_repository=abi_args.new_crypto_repo,
|
||||
crypto_revision=abi_args.new_crypto_rev,
|
||||
abi_dumps={},
|
||||
storage_tests={},
|
||||
modules={}
|
||||
)
|
||||
configuration = SimpleNamespace(
|
||||
@ -430,6 +594,9 @@ def run_main():
|
||||
report_dir=abi_args.report_dir,
|
||||
keep_all_reports=abi_args.keep_all_reports,
|
||||
brief=abi_args.brief,
|
||||
check_abi=abi_args.check_abi,
|
||||
check_api=abi_args.check_api,
|
||||
check_storage=abi_args.check_storage,
|
||||
skip_file=abi_args.skip_file
|
||||
)
|
||||
abi_check = AbiChecker(old_version, new_version, configuration)
|
||||
|
@ -407,14 +407,15 @@ def check_output(generated_output_file, main_input_file, merged_files):
|
||||
is also present in an output file. This is not perfect but good enough
|
||||
for now.
|
||||
"""
|
||||
generated_output = set(open(generated_output_file, 'r', encoding='utf-8'))
|
||||
for line in open(main_input_file, 'r', encoding='utf-8'):
|
||||
if line not in generated_output:
|
||||
raise LostContent('original file', line)
|
||||
for merged_file in merged_files:
|
||||
for line in open(merged_file, 'r', encoding='utf-8'):
|
||||
with open(generated_output_file, 'r', encoding='utf-8') as fd:
|
||||
generated_output = set(fd)
|
||||
for line in open(main_input_file, 'r', encoding='utf-8'):
|
||||
if line not in generated_output:
|
||||
raise LostContent(merged_file, line)
|
||||
raise LostContent('original file', line)
|
||||
for merged_file in merged_files:
|
||||
for line in open(merged_file, 'r', encoding='utf-8'):
|
||||
if line not in generated_output:
|
||||
raise LostContent(merged_file, line)
|
||||
|
||||
def finish_output(changelog, output_file, input_file, merged_files):
|
||||
"""Write the changelog to the output file.
|
||||
|
@ -18,7 +18,7 @@
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from typing import Dict, Iterable, Iterator, List, Optional, Pattern, Set, Tuple, Union
|
||||
from typing import Dict, IO, Iterable, Iterator, List, Optional, Pattern, Set, Tuple, Union
|
||||
|
||||
|
||||
class ReadFileLineException(Exception):
|
||||
@ -50,12 +50,13 @@ class read_file_lines:
|
||||
"""
|
||||
def __init__(self, filename: str, binary: bool = False) -> None:
|
||||
self.filename = filename
|
||||
self.file = None #type: Optional[IO[str]]
|
||||
self.line_number = 'entry' #type: Union[int, str]
|
||||
self.generator = None #type: Optional[Iterable[Tuple[int, str]]]
|
||||
self.binary = binary
|
||||
def __enter__(self) -> 'read_file_lines':
|
||||
self.generator = enumerate(open(self.filename,
|
||||
'rb' if self.binary else 'r'))
|
||||
self.file = open(self.filename, 'rb' if self.binary else 'r')
|
||||
self.generator = enumerate(self.file)
|
||||
return self
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
assert self.generator is not None
|
||||
@ -64,6 +65,8 @@ class read_file_lines:
|
||||
yield content
|
||||
self.line_number = 'exit'
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
if self.file is not None:
|
||||
self.file.close()
|
||||
if exc_type is not None:
|
||||
raise ReadFileLineException(self.filename, self.line_number) \
|
||||
from exc_value
|
||||
|
@ -725,6 +725,8 @@ class TestGenerator:
|
||||
filename = self.filename_for(basename)
|
||||
test_case.write_data_file(filename, test_cases)
|
||||
|
||||
# Note that targets whose name containns 'test_format' have their content
|
||||
# validated by `abi_check.py`.
|
||||
TARGETS = {
|
||||
'test_suite_psa_crypto_generate_key.generated':
|
||||
lambda info: KeyGenerate(info).test_cases_for_key_generation(),
|
||||
|
Loading…
Reference in New Issue
Block a user