blob: 15b83d8e856dff7043003d6c92a821e294e4476f [file] [log] [blame]
Gilles Peskine24827022018-09-25 18:49:23 +02001#!/usr/bin/env python3
Gilles Peskinea3b93ff2019-06-03 11:23:56 +02002"""Test the program psa_constant_names.
Gilles Peskine24827022018-09-25 18:49:23 +02003Gather constant names from header files and test cases. Compile a C program
4to print out their numerical values, feed these numerical values to
5psa_constant_names, and check that the output is the original name.
6Return 0 if all test cases pass, 1 if the output was not always as expected,
Gilles Peskinea3b93ff2019-06-03 11:23:56 +02007or 1 (with a Python backtrace) if there was an operational error.
8"""
Gilles Peskine24827022018-09-25 18:49:23 +02009
Bence Szépkúti1e148272020-08-07 13:07:28 +020010# Copyright The Mbed TLS Contributors
Bence Szépkútic7da1fe2020-05-26 01:54:15 +020011# SPDX-License-Identifier: Apache-2.0
12#
13# Licensed under the Apache License, Version 2.0 (the "License"); you may
14# not use this file except in compliance with the License.
15# You may obtain a copy of the License at
16#
17# http://www.apache.org/licenses/LICENSE-2.0
18#
19# Unless required by applicable law or agreed to in writing, software
20# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
21# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22# See the License for the specific language governing permissions and
23# limitations under the License.
Bence Szépkúti700ee442020-05-26 00:33:31 +020024
Gilles Peskine24827022018-09-25 18:49:23 +020025import argparse
Gilles Peskinea5000f12019-11-21 17:51:11 +010026from collections import namedtuple
Gilles Peskine24827022018-09-25 18:49:23 +020027import os
Gilles Peskine24827022018-09-25 18:49:23 +020028import re
29import subprocess
30import sys
Gilles Peskine2adebc82020-12-11 00:30:53 +010031
32import scripts_path # pylint: disable=unused-import
33from mbedtls_dev import c_build_helper
Gilles Peskine22fcf1b2021-03-10 01:02:39 +010034from mbedtls_dev import macro_collector
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +010035
Gilles Peskinea0a315c2018-10-19 11:27:10 +020036class ReadFileLineException(Exception):
37 def __init__(self, filename, line_number):
38 message = 'in {} at {}'.format(filename, line_number)
39 super(ReadFileLineException, self).__init__(message)
40 self.filename = filename
41 self.line_number = line_number
42
43class read_file_lines:
Gilles Peskine54f54452019-05-27 18:31:59 +020044 # Dear Pylint, conventionally, a context manager class name is lowercase.
45 # pylint: disable=invalid-name,too-few-public-methods
Gilles Peskinea3b93ff2019-06-03 11:23:56 +020046 """Context manager to read a text file line by line.
47
48 ```
49 with read_file_lines(filename) as lines:
50 for line in lines:
51 process(line)
52 ```
53 is equivalent to
54 ```
55 with open(filename, 'r') as input_file:
56 for line in input_file:
57 process(line)
58 ```
59 except that if process(line) raises an exception, then the read_file_lines
60 snippet annotates the exception with the file name and line number.
61 """
Gilles Peskine49af2d32019-12-06 19:20:13 +010062 def __init__(self, filename, binary=False):
Gilles Peskinea0a315c2018-10-19 11:27:10 +020063 self.filename = filename
64 self.line_number = 'entry'
Gilles Peskine54f54452019-05-27 18:31:59 +020065 self.generator = None
Gilles Peskine49af2d32019-12-06 19:20:13 +010066 self.binary = binary
Gilles Peskinea0a315c2018-10-19 11:27:10 +020067 def __enter__(self):
Gilles Peskine49af2d32019-12-06 19:20:13 +010068 self.generator = enumerate(open(self.filename,
69 'rb' if self.binary else 'r'))
Gilles Peskinea0a315c2018-10-19 11:27:10 +020070 return self
71 def __iter__(self):
72 for line_number, content in self.generator:
73 self.line_number = line_number
74 yield content
75 self.line_number = 'exit'
Gilles Peskine42a0a0a2019-05-27 18:29:47 +020076 def __exit__(self, exc_type, exc_value, exc_traceback):
77 if exc_type is not None:
Gilles Peskinea0a315c2018-10-19 11:27:10 +020078 raise ReadFileLineException(self.filename, self.line_number) \
Gilles Peskine42a0a0a2019-05-27 18:29:47 +020079 from exc_value
Gilles Peskinea0a315c2018-10-19 11:27:10 +020080
Gilles Peskine22fcf1b2021-03-10 01:02:39 +010081class InputsForTest(macro_collector.PSAMacroEnumerator):
Gilles Peskine8c8694c2019-11-21 19:22:45 +010082 # pylint: disable=too-many-instance-attributes
Gilles Peskinea3b93ff2019-06-03 11:23:56 +020083 """Accumulate information about macros to test.
Gilles Peskine4408dfd2019-11-21 17:16:21 +010084
Gilles Peskinea3b93ff2019-06-03 11:23:56 +020085 This includes macro names as well as information about their arguments
86 when applicable.
87 """
88
Gilles Peskine24827022018-09-25 18:49:23 +020089 def __init__(self):
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +010090 super().__init__()
Gilles Peskine2bcfc712019-11-21 19:49:26 +010091 self.all_declared = set()
Gilles Peskine24827022018-09-25 18:49:23 +020092 # Sets of names per type
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +010093 self.statuses.add('PSA_SUCCESS')
94 self.algorithms.add('0xffffffff')
95 self.ecc_curves.add('0xff')
96 self.dh_groups.add('0xff')
97 self.key_types.add('0xffff')
98 self.key_usage_flags.add('0x80000000')
99
Bence Szépkúti4af65602020-12-08 11:10:21 +0100100 # Hard-coded values for unknown algorithms
101 #
102 # These have to have values that are correct for their respective
103 # PSA_ALG_IS_xxx macros, but are also not currently assigned and are
104 # not likely to be assigned in the near future.
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +0100105 self.hash_algorithms.add('0x020000fe') # 0x020000ff is PSA_ALG_ANY_HASH
106 self.mac_algorithms.add('0x03007fff')
107 self.ka_algorithms.add('0x09fc0000')
108 self.kdf_algorithms.add('0x080000ff')
Gilles Peskine434899f2018-10-19 11:30:26 +0200109 # For AEAD algorithms, the only variability is over the tag length,
110 # and this only applies to known algorithms, so don't test an
111 # unknown algorithm.
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +0100112
Gilles Peskine24827022018-09-25 18:49:23 +0200113 # Identifier prefixes
114 self.table_by_prefix = {
115 'ERROR': self.statuses,
116 'ALG': self.algorithms,
Gilles Peskine98a710c2019-11-21 18:58:36 +0100117 'ECC_CURVE': self.ecc_curves,
118 'DH_GROUP': self.dh_groups,
Gilles Peskine24827022018-09-25 18:49:23 +0200119 'KEY_TYPE': self.key_types,
120 'KEY_USAGE': self.key_usage_flags,
121 }
Gilles Peskine8c8694c2019-11-21 19:22:45 +0100122 # Test functions
123 self.table_by_test_function = {
Gilles Peskine8fa13482019-11-25 17:10:12 +0100124 # Any function ending in _algorithm also gets added to
125 # self.algorithms.
126 'key_type': [self.key_types],
Gilles Peskinef8210f22019-12-02 17:26:44 +0100127 'block_cipher_key_type': [self.key_types],
128 'stream_cipher_key_type': [self.key_types],
Gilles Peskine228abc52019-12-03 17:24:19 +0100129 'ecc_key_family': [self.ecc_curves],
Gilles Peskine8fa13482019-11-25 17:10:12 +0100130 'ecc_key_types': [self.ecc_curves],
Gilles Peskine228abc52019-12-03 17:24:19 +0100131 'dh_key_family': [self.dh_groups],
Gilles Peskine8fa13482019-11-25 17:10:12 +0100132 'dh_key_types': [self.dh_groups],
133 'hash_algorithm': [self.hash_algorithms],
134 'mac_algorithm': [self.mac_algorithms],
135 'cipher_algorithm': [],
136 'hmac_algorithm': [self.mac_algorithms],
137 'aead_algorithm': [self.aead_algorithms],
138 'key_derivation_algorithm': [self.kdf_algorithms],
139 'key_agreement_algorithm': [self.ka_algorithms],
140 'asymmetric_signature_algorithm': [],
141 'asymmetric_signature_wildcard': [self.algorithms],
142 'asymmetric_encryption_algorithm': [],
143 'other_algorithm': [],
Gilles Peskine8c8694c2019-11-21 19:22:45 +0100144 }
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +0100145 self.arguments_for['mac_length'] += ['1', '63']
146 self.arguments_for['min_mac_length'] += ['1', '63']
147 self.arguments_for['tag_length'] += ['1', '63']
148 self.arguments_for['min_tag_length'] += ['1', '63']
Gilles Peskine24827022018-09-25 18:49:23 +0200149
Gilles Peskineffe2d6e2019-11-21 17:17:01 +0100150 def get_names(self, type_word):
151 """Return the set of known names of values of the given type."""
152 return {
153 'status': self.statuses,
154 'algorithm': self.algorithms,
155 'ecc_curve': self.ecc_curves,
156 'dh_group': self.dh_groups,
157 'key_type': self.key_types,
158 'key_usage': self.key_usage_flags,
159 }[type_word]
160
Gilles Peskine24827022018-09-25 18:49:23 +0200161 # Regex for interesting header lines.
162 # Groups: 1=macro name, 2=type, 3=argument list (optional).
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200163 _header_line_re = \
Gilles Peskine24827022018-09-25 18:49:23 +0200164 re.compile(r'#define +' +
Gilles Peskine98a710c2019-11-21 18:58:36 +0100165 r'(PSA_((?:(?:DH|ECC|KEY)_)?[A-Z]+)_\w+)' +
Gilles Peskine24827022018-09-25 18:49:23 +0200166 r'(?:\(([^\n()]*)\))?')
167 # Regex of macro names to exclude.
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200168 _excluded_name_re = re.compile(r'_(?:GET|IS|OF)_|_(?:BASE|FLAG|MASK)\Z')
Gilles Peskinec68ce962018-10-19 11:31:52 +0200169 # Additional excluded macros.
Gilles Peskine5c196fb2019-05-17 12:04:41 +0200170 _excluded_names = set([
171 # Macros that provide an alternative way to build the same
172 # algorithm as another macro.
Bence Szépkútia63b20d2020-12-16 11:36:46 +0100173 'PSA_ALG_AEAD_WITH_DEFAULT_LENGTH_TAG',
Gilles Peskine5c196fb2019-05-17 12:04:41 +0200174 'PSA_ALG_FULL_LENGTH_MAC',
175 # Auxiliary macro whose name doesn't fit the usual patterns for
176 # auxiliary macros.
Bence Szépkútia63b20d2020-12-16 11:36:46 +0100177 'PSA_ALG_AEAD_WITH_DEFAULT_LENGTH_TAG_CASE',
Gilles Peskine5c196fb2019-05-17 12:04:41 +0200178 ])
Gilles Peskine24827022018-09-25 18:49:23 +0200179 def parse_header_line(self, line):
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200180 """Parse a C header line, looking for "#define PSA_xxx"."""
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200181 m = re.match(self._header_line_re, line)
Gilles Peskine24827022018-09-25 18:49:23 +0200182 if not m:
183 return
184 name = m.group(1)
Gilles Peskine2bcfc712019-11-21 19:49:26 +0100185 self.all_declared.add(name)
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200186 if re.search(self._excluded_name_re, name) or \
187 name in self._excluded_names:
Gilles Peskine24827022018-09-25 18:49:23 +0200188 return
189 dest = self.table_by_prefix.get(m.group(2))
190 if dest is None:
191 return
192 dest.add(name)
193 if m.group(3):
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200194 self.argspecs[name] = self._argument_split(m.group(3))
Gilles Peskine24827022018-09-25 18:49:23 +0200195
Gilles Peskine49af2d32019-12-06 19:20:13 +0100196 _nonascii_re = re.compile(rb'[^\x00-\x7f]+')
Gilles Peskine24827022018-09-25 18:49:23 +0200197 def parse_header(self, filename):
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200198 """Parse a C header file, looking for "#define PSA_xxx"."""
Gilles Peskine49af2d32019-12-06 19:20:13 +0100199 with read_file_lines(filename, binary=True) as lines:
Gilles Peskinea0a315c2018-10-19 11:27:10 +0200200 for line in lines:
Gilles Peskine49af2d32019-12-06 19:20:13 +0100201 line = re.sub(self._nonascii_re, rb'', line).decode('ascii')
Gilles Peskine24827022018-09-25 18:49:23 +0200202 self.parse_header_line(line)
203
Gilles Peskine49af2d32019-12-06 19:20:13 +0100204 _macro_identifier_re = re.compile(r'[A-Z]\w+')
Gilles Peskine2bcfc712019-11-21 19:49:26 +0100205 def generate_undeclared_names(self, expr):
206 for name in re.findall(self._macro_identifier_re, expr):
207 if name not in self.all_declared:
208 yield name
209
210 def accept_test_case_line(self, function, argument):
211 #pylint: disable=unused-argument
212 undeclared = list(self.generate_undeclared_names(argument))
213 if undeclared:
214 raise Exception('Undeclared names in test case', undeclared)
215 return True
216
Gilles Peskine24827022018-09-25 18:49:23 +0200217 def add_test_case_line(self, function, argument):
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200218 """Parse a test case data line, looking for algorithm metadata tests."""
Gilles Peskine8c8694c2019-11-21 19:22:45 +0100219 sets = []
Gilles Peskine24827022018-09-25 18:49:23 +0200220 if function.endswith('_algorithm'):
Gilles Peskine8c8694c2019-11-21 19:22:45 +0100221 sets.append(self.algorithms)
Gilles Peskine79616682019-11-21 20:08:10 +0100222 if function == 'key_agreement_algorithm' and \
223 argument.startswith('PSA_ALG_KEY_AGREEMENT('):
224 # We only want *raw* key agreement algorithms as such, so
225 # exclude ones that are already chained with a KDF.
226 # Keep the expression as one to test as an algorithm.
227 function = 'other_algorithm'
Gilles Peskine8fa13482019-11-25 17:10:12 +0100228 sets += self.table_by_test_function[function]
Gilles Peskine2bcfc712019-11-21 19:49:26 +0100229 if self.accept_test_case_line(function, argument):
230 for s in sets:
231 s.add(argument)
Gilles Peskine24827022018-09-25 18:49:23 +0200232
233 # Regex matching a *.data line containing a test function call and
234 # its arguments. The actual definition is partly positional, but this
235 # regex is good enough in practice.
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200236 _test_case_line_re = re.compile(r'(?!depends_on:)(\w+):([^\n :][^:\n]*)')
Gilles Peskine24827022018-09-25 18:49:23 +0200237 def parse_test_cases(self, filename):
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200238 """Parse a test case file (*.data), looking for algorithm metadata tests."""
Gilles Peskinea0a315c2018-10-19 11:27:10 +0200239 with read_file_lines(filename) as lines:
240 for line in lines:
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200241 m = re.match(self._test_case_line_re, line)
Gilles Peskine24827022018-09-25 18:49:23 +0200242 if m:
243 self.add_test_case_line(m.group(1), m.group(2))
244
Gilles Peskine6f7ba5f2021-03-10 00:50:18 +0100245def gather_inputs(headers, test_suites, inputs_class=InputsForTest):
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200246 """Read the list of inputs to test psa_constant_names with."""
Gilles Peskine84a45812019-11-21 19:50:33 +0100247 inputs = inputs_class()
Gilles Peskine24827022018-09-25 18:49:23 +0200248 for header in headers:
249 inputs.parse_header(header)
250 for test_cases in test_suites:
251 inputs.parse_test_cases(test_cases)
252 inputs.gather_arguments()
253 return inputs
254
Gilles Peskineb86b6d32019-11-21 17:26:10 +0100255def run_c(type_word, expressions, include_path=None, keep_c=False):
Gilles Peskine2991b5f2021-01-19 21:19:02 +0100256 """Generate and run a program to print out numerical values of C expressions."""
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200257 if type_word == 'status':
Gilles Peskinec4cd2ad2019-02-13 18:42:53 +0100258 cast_to = 'long'
259 printf_format = '%ld'
260 else:
261 cast_to = 'unsigned long'
262 printf_format = '0x%08lx'
Gilles Peskine2adebc82020-12-11 00:30:53 +0100263 return c_build_helper.get_c_expression_values(
Gilles Peskinefc622112020-12-11 00:27:14 +0100264 cast_to, printf_format,
265 expressions,
266 caller='test_psa_constant_names.py for {} values'.format(type_word),
267 file_label=type_word,
268 header='#include <psa/crypto.h>',
269 include_path=include_path,
270 keep_c=keep_c
271 )
Gilles Peskine24827022018-09-25 18:49:23 +0200272
Gilles Peskine42a0a0a2019-05-27 18:29:47 +0200273NORMALIZE_STRIP_RE = re.compile(r'\s+')
Gilles Peskine24827022018-09-25 18:49:23 +0200274def normalize(expr):
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200275 """Normalize the C expression so as not to care about trivial differences.
Gilles Peskine4408dfd2019-11-21 17:16:21 +0100276
Gilles Peskinea3b93ff2019-06-03 11:23:56 +0200277 Currently "trivial differences" means whitespace.
278 """
Gilles Peskine5a6dc892019-11-21 16:48:07 +0100279 return re.sub(NORMALIZE_STRIP_RE, '', expr)
Gilles Peskine24827022018-09-25 18:49:23 +0200280
Gilles Peskineb86b6d32019-11-21 17:26:10 +0100281def collect_values(inputs, type_word, include_path=None, keep_c=False):
Gilles Peskinec2317112019-11-21 17:17:39 +0100282 """Generate expressions using known macro names and calculate their values.
283
284 Return a list of pairs of (expr, value) where expr is an expression and
285 value is a string representation of its integer value.
286 """
287 names = inputs.get_names(type_word)
288 expressions = sorted(inputs.generate_expressions(names))
Gilles Peskineb86b6d32019-11-21 17:26:10 +0100289 values = run_c(type_word, expressions,
290 include_path=include_path, keep_c=keep_c)
Gilles Peskinec2317112019-11-21 17:17:39 +0100291 return expressions, values
292
Gilles Peskine24609332019-11-21 17:44:21 +0100293class Tests:
294 """An object representing tests and their results."""
Gilles Peskine4408dfd2019-11-21 17:16:21 +0100295
Gilles Peskinea5000f12019-11-21 17:51:11 +0100296 Error = namedtuple('Error',
297 ['type', 'expression', 'value', 'output'])
298
Gilles Peskine24609332019-11-21 17:44:21 +0100299 def __init__(self, options):
300 self.options = options
301 self.count = 0
302 self.errors = []
Gilles Peskine4408dfd2019-11-21 17:16:21 +0100303
Gilles Peskine24609332019-11-21 17:44:21 +0100304 def run_one(self, inputs, type_word):
305 """Test psa_constant_names for the specified type.
Gilles Peskine24827022018-09-25 18:49:23 +0200306
Gilles Peskine24609332019-11-21 17:44:21 +0100307 Run the program on the names for this type.
308 Use the inputs to figure out what arguments to pass to macros that
309 take arguments.
310 """
311 expressions, values = collect_values(inputs, type_word,
312 include_path=self.options.include,
313 keep_c=self.options.keep_c)
314 output = subprocess.check_output([self.options.program, type_word] +
315 values)
316 outputs = output.decode('ascii').strip().split('\n')
317 self.count += len(expressions)
318 for expr, value, output in zip(expressions, values, outputs):
Gilles Peskine32558482019-12-03 19:03:35 +0100319 if self.options.show:
320 sys.stdout.write('{} {}\t{}\n'.format(type_word, value, output))
Gilles Peskine24609332019-11-21 17:44:21 +0100321 if normalize(expr) != normalize(output):
Gilles Peskinea5000f12019-11-21 17:51:11 +0100322 self.errors.append(self.Error(type=type_word,
323 expression=expr,
324 value=value,
325 output=output))
Gilles Peskine24827022018-09-25 18:49:23 +0200326
Gilles Peskine24609332019-11-21 17:44:21 +0100327 def run_all(self, inputs):
328 """Run psa_constant_names on all the gathered inputs."""
329 for type_word in ['status', 'algorithm', 'ecc_curve', 'dh_group',
330 'key_type', 'key_usage']:
331 self.run_one(inputs, type_word)
Gilles Peskine4408dfd2019-11-21 17:16:21 +0100332
Gilles Peskine24609332019-11-21 17:44:21 +0100333 def report(self, out):
334 """Describe each case where the output is not as expected.
335
336 Write the errors to ``out``.
337 Also write a total.
338 """
Gilles Peskinea5000f12019-11-21 17:51:11 +0100339 for error in self.errors:
Gilles Peskine24609332019-11-21 17:44:21 +0100340 out.write('For {} "{}", got "{}" (value: {})\n'
Gilles Peskinea5000f12019-11-21 17:51:11 +0100341 .format(error.type, error.expression,
342 error.output, error.value))
Gilles Peskine24609332019-11-21 17:44:21 +0100343 out.write('{} test cases'.format(self.count))
344 if self.errors:
345 out.write(', {} FAIL\n'.format(len(self.errors)))
346 else:
347 out.write(' PASS\n')
Gilles Peskine24827022018-09-25 18:49:23 +0200348
Gilles Peskine69f93b52019-11-21 16:49:50 +0100349HEADERS = ['psa/crypto.h', 'psa/crypto_extra.h', 'psa/crypto_values.h']
350TEST_SUITES = ['tests/suites/test_suite_psa_crypto_metadata.data']
351
Gilles Peskine54f54452019-05-27 18:31:59 +0200352def main():
Gilles Peskine24827022018-09-25 18:49:23 +0200353 parser = argparse.ArgumentParser(description=globals()['__doc__'])
354 parser.add_argument('--include', '-I',
355 action='append', default=['include'],
356 help='Directory for header files')
Gilles Peskinecf9c18e2018-10-19 11:28:42 +0200357 parser.add_argument('--keep-c',
358 action='store_true', dest='keep_c', default=False,
359 help='Keep the intermediate C file')
360 parser.add_argument('--no-keep-c',
361 action='store_false', dest='keep_c',
362 help='Don\'t keep the intermediate C file (default)')
Gilles Peskine8f5a5012019-11-21 16:49:10 +0100363 parser.add_argument('--program',
364 default='programs/psa/psa_constant_names',
365 help='Program to test')
Gilles Peskine32558482019-12-03 19:03:35 +0100366 parser.add_argument('--show',
367 action='store_true',
Gilles Peskinec893a572021-03-17 13:45:32 +0100368 help='Show tested values on stdout')
Gilles Peskine32558482019-12-03 19:03:35 +0100369 parser.add_argument('--no-show',
370 action='store_false', dest='show',
371 help='Don\'t show tested values (default)')
Gilles Peskine24827022018-09-25 18:49:23 +0200372 options = parser.parse_args()
Gilles Peskine69f93b52019-11-21 16:49:50 +0100373 headers = [os.path.join(options.include[0], h) for h in HEADERS]
374 inputs = gather_inputs(headers, TEST_SUITES)
Gilles Peskine24609332019-11-21 17:44:21 +0100375 tests = Tests(options)
376 tests.run_all(inputs)
377 tests.report(sys.stdout)
378 if tests.errors:
Gilles Peskine8b022352020-03-24 18:36:56 +0100379 sys.exit(1)
Gilles Peskine54f54452019-05-27 18:31:59 +0200380
381if __name__ == '__main__':
382 main()