blob: 30f82db257aa71aad64911ba0ceac7f8115c4849 [file] [log] [blame]
Gilles Peskine09940492021-01-26 22:16:30 +01001#!/usr/bin/env python3
2"""Generate test data for PSA cryptographic mechanisms.
Gilles Peskine0298bda2021-03-10 02:34:37 +01003
4With no arguments, generate all test data. With non-option arguments,
5generate only the specified files.
Gilles Peskine09940492021-01-26 22:16:30 +01006"""
7
8# Copyright The Mbed TLS Contributors
9# SPDX-License-Identifier: Apache-2.0
10#
11# Licensed under the Apache License, Version 2.0 (the "License"); you may
12# not use this file except in compliance with the License.
13# You may obtain a copy of the License at
14#
15# http://www.apache.org/licenses/LICENSE-2.0
16#
17# Unless required by applicable law or agreed to in writing, software
18# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
19# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20# See the License for the specific language governing permissions and
21# limitations under the License.
22
23import argparse
Gilles Peskine14e428f2021-01-26 22:19:21 +010024import os
Bence Szépkúti9e84ec72021-05-07 11:49:17 +020025import posixpath
Gilles Peskine14e428f2021-01-26 22:19:21 +010026import re
Gilles Peskine09940492021-01-26 22:16:30 +010027import sys
Gilles Peskine3d778392021-02-17 15:11:05 +010028from typing import Callable, Dict, FrozenSet, Iterable, Iterator, List, Optional, TypeVar
Gilles Peskine09940492021-01-26 22:16:30 +010029
30import scripts_path # pylint: disable=unused-import
Gilles Peskinec86f20a2021-04-22 00:20:47 +020031from mbedtls_dev import build_tree
Gilles Peskine14e428f2021-01-26 22:19:21 +010032from mbedtls_dev import crypto_knowledge
Gilles Peskine09940492021-01-26 22:16:30 +010033from mbedtls_dev import macro_collector
Gilles Peskine897dff92021-03-10 15:03:44 +010034from mbedtls_dev import psa_storage
Gilles Peskine14e428f2021-01-26 22:19:21 +010035from mbedtls_dev import test_case
Gilles Peskine09940492021-01-26 22:16:30 +010036
37T = TypeVar('T') #pylint: disable=invalid-name
38
Gilles Peskine14e428f2021-01-26 22:19:21 +010039
Gilles Peskine7f756872021-02-16 12:13:12 +010040def psa_want_symbol(name: str) -> str:
Gilles Peskineaf172842021-01-27 18:24:48 +010041 """Return the PSA_WANT_xxx symbol associated with a PSA crypto feature."""
42 if name.startswith('PSA_'):
43 return name[:4] + 'WANT_' + name[4:]
44 else:
45 raise ValueError('Unable to determine the PSA_WANT_ symbol for ' + name)
46
Gilles Peskine7f756872021-02-16 12:13:12 +010047def finish_family_dependency(dep: str, bits: int) -> str:
48 """Finish dep if it's a family dependency symbol prefix.
49
50 A family dependency symbol prefix is a PSA_WANT_ symbol that needs to be
51 qualified by the key size. If dep is such a symbol, finish it by adjusting
52 the prefix and appending the key size. Other symbols are left unchanged.
53 """
54 return re.sub(r'_FAMILY_(.*)', r'_\1_' + str(bits), dep)
55
56def finish_family_dependencies(dependencies: List[str], bits: int) -> List[str]:
57 """Finish any family dependency symbol prefixes.
58
59 Apply `finish_family_dependency` to each element of `dependencies`.
60 """
61 return [finish_family_dependency(dep, bits) for dep in dependencies]
Gilles Peskineaf172842021-01-27 18:24:48 +010062
Gilles Peskinef8223ab2021-03-10 15:07:16 +010063def automatic_dependencies(*expressions: str) -> List[str]:
64 """Infer dependencies of a test case by looking for PSA_xxx symbols.
65
66 The arguments are strings which should be C expressions. Do not use
67 string literals or comments as this function is not smart enough to
68 skip them.
69 """
70 used = set()
71 for expr in expressions:
72 used.update(re.findall(r'PSA_(?:ALG|ECC_FAMILY|KEY_TYPE)_\w+', expr))
73 return sorted(psa_want_symbol(name) for name in used)
74
Gilles Peskined169d602021-02-16 14:16:25 +010075# A temporary hack: at the time of writing, not all dependency symbols
76# are implemented yet. Skip test cases for which the dependency symbols are
77# not available. Once all dependency symbols are available, this hack must
78# be removed so that a bug in the dependency symbols proprely leads to a test
79# failure.
80def read_implemented_dependencies(filename: str) -> FrozenSet[str]:
81 return frozenset(symbol
82 for line in open(filename)
83 for symbol in re.findall(r'\bPSA_WANT_\w+\b', line))
Gilles Peskinec86f20a2021-04-22 00:20:47 +020084_implemented_dependencies = None #type: Optional[FrozenSet[str]] #pylint: disable=invalid-name
Gilles Peskined169d602021-02-16 14:16:25 +010085def hack_dependencies_not_implemented(dependencies: List[str]) -> None:
Gilles Peskinec86f20a2021-04-22 00:20:47 +020086 global _implemented_dependencies #pylint: disable=global-statement,invalid-name
87 if _implemented_dependencies is None:
88 _implemented_dependencies = \
89 read_implemented_dependencies('include/psa/crypto_config.h')
90 if not all(dep.lstrip('!') in _implemented_dependencies
Gilles Peskined169d602021-02-16 14:16:25 +010091 for dep in dependencies):
92 dependencies.append('DEPENDENCY_NOT_IMPLEMENTED_YET')
93
Gilles Peskine14e428f2021-01-26 22:19:21 +010094
Gilles Peskineb94ea512021-03-10 02:12:08 +010095class Information:
96 """Gather information about PSA constructors."""
Gilles Peskine09940492021-01-26 22:16:30 +010097
Gilles Peskineb94ea512021-03-10 02:12:08 +010098 def __init__(self) -> None:
Gilles Peskine09940492021-01-26 22:16:30 +010099 self.constructors = self.read_psa_interface()
100
101 @staticmethod
Gilles Peskine09940492021-01-26 22:16:30 +0100102 def remove_unwanted_macros(
103 constructors: macro_collector.PSAMacroCollector
104 ) -> None:
105 # Mbed TLS doesn't support DSA. Don't attempt to generate any related
106 # test case.
107 constructors.key_types.discard('PSA_KEY_TYPE_DSA_KEY_PAIR')
108 constructors.key_types.discard('PSA_KEY_TYPE_DSA_PUBLIC_KEY')
109 constructors.algorithms_from_hash.pop('PSA_ALG_DSA', None)
110 constructors.algorithms_from_hash.pop('PSA_ALG_DETERMINISTIC_DSA', None)
111
112 def read_psa_interface(self) -> macro_collector.PSAMacroCollector:
113 """Return the list of known key types, algorithms, etc."""
114 constructors = macro_collector.PSAMacroCollector()
115 header_file_names = ['include/psa/crypto_values.h',
116 'include/psa/crypto_extra.h']
117 for header_file_name in header_file_names:
118 with open(header_file_name, 'rb') as header_file:
119 constructors.read_file(header_file)
120 self.remove_unwanted_macros(constructors)
121 return constructors
122
Gilles Peskine14e428f2021-01-26 22:19:21 +0100123
Gilles Peskineb94ea512021-03-10 02:12:08 +0100124def test_case_for_key_type_not_supported(
125 verb: str, key_type: str, bits: int,
126 dependencies: List[str],
127 *args: str,
128 param_descr: str = ''
129) -> test_case.TestCase:
130 """Return one test case exercising a key creation method
131 for an unsupported key type or size.
132 """
133 hack_dependencies_not_implemented(dependencies)
134 tc = test_case.TestCase()
135 short_key_type = re.sub(r'PSA_(KEY_TYPE|ECC_FAMILY)_', r'', key_type)
136 adverb = 'not' if dependencies else 'never'
137 if param_descr:
138 adverb = param_descr + ' ' + adverb
139 tc.set_description('PSA {} {} {}-bit {} supported'
140 .format(verb, short_key_type, bits, adverb))
141 tc.set_dependencies(dependencies)
142 tc.set_function(verb + '_not_supported')
143 tc.set_arguments([key_type] + list(args))
144 return tc
145
146class NotSupported:
147 """Generate test cases for when something is not supported."""
148
149 def __init__(self, info: Information) -> None:
150 self.constructors = info.constructors
Gilles Peskine14e428f2021-01-26 22:19:21 +0100151
Gilles Peskine60b29fe2021-02-16 14:06:50 +0100152 ALWAYS_SUPPORTED = frozenset([
153 'PSA_KEY_TYPE_DERIVE',
154 'PSA_KEY_TYPE_RAW_DATA',
155 ])
Gilles Peskine14e428f2021-01-26 22:19:21 +0100156 def test_cases_for_key_type_not_supported(
Gilles Peskine60b29fe2021-02-16 14:06:50 +0100157 self,
Gilles Peskineaf172842021-01-27 18:24:48 +0100158 kt: crypto_knowledge.KeyType,
159 param: Optional[int] = None,
160 param_descr: str = '',
Gilles Peskine3d778392021-02-17 15:11:05 +0100161 ) -> Iterator[test_case.TestCase]:
Gilles Peskineaf172842021-01-27 18:24:48 +0100162 """Return test cases exercising key creation when the given type is unsupported.
163
164 If param is present and not None, emit test cases conditioned on this
165 parameter not being supported. If it is absent or None, emit test cases
166 conditioned on the base type not being supported.
167 """
Gilles Peskine60b29fe2021-02-16 14:06:50 +0100168 if kt.name in self.ALWAYS_SUPPORTED:
169 # Don't generate test cases for key types that are always supported.
170 # They would be skipped in all configurations, which is noise.
Gilles Peskine3d778392021-02-17 15:11:05 +0100171 return
Gilles Peskineaf172842021-01-27 18:24:48 +0100172 import_dependencies = [('!' if param is None else '') +
173 psa_want_symbol(kt.name)]
174 if kt.params is not None:
175 import_dependencies += [('!' if param == i else '') +
176 psa_want_symbol(sym)
177 for i, sym in enumerate(kt.params)]
Gilles Peskine14e428f2021-01-26 22:19:21 +0100178 if kt.name.endswith('_PUBLIC_KEY'):
179 generate_dependencies = []
180 else:
181 generate_dependencies = import_dependencies
Gilles Peskine14e428f2021-01-26 22:19:21 +0100182 for bits in kt.sizes_to_test():
Gilles Peskine3d778392021-02-17 15:11:05 +0100183 yield test_case_for_key_type_not_supported(
Gilles Peskine7f756872021-02-16 12:13:12 +0100184 'import', kt.expression, bits,
185 finish_family_dependencies(import_dependencies, bits),
Gilles Peskineaf172842021-01-27 18:24:48 +0100186 test_case.hex_string(kt.key_material(bits)),
187 param_descr=param_descr,
Gilles Peskine3d778392021-02-17 15:11:05 +0100188 )
Gilles Peskineaf172842021-01-27 18:24:48 +0100189 if not generate_dependencies and param is not None:
190 # If generation is impossible for this key type, rather than
191 # supported or not depending on implementation capabilities,
192 # only generate the test case once.
193 continue
Gilles Peskine3d778392021-02-17 15:11:05 +0100194 yield test_case_for_key_type_not_supported(
Gilles Peskine7f756872021-02-16 12:13:12 +0100195 'generate', kt.expression, bits,
196 finish_family_dependencies(generate_dependencies, bits),
Gilles Peskineaf172842021-01-27 18:24:48 +0100197 str(bits),
198 param_descr=param_descr,
Gilles Peskine3d778392021-02-17 15:11:05 +0100199 )
Gilles Peskine14e428f2021-01-26 22:19:21 +0100200 # To be added: derive
Gilles Peskine14e428f2021-01-26 22:19:21 +0100201
Gilles Peskine3d778392021-02-17 15:11:05 +0100202 def test_cases_for_not_supported(self) -> Iterator[test_case.TestCase]:
Gilles Peskine14e428f2021-01-26 22:19:21 +0100203 """Generate test cases that exercise the creation of keys of unsupported types."""
Gilles Peskine14e428f2021-01-26 22:19:21 +0100204 for key_type in sorted(self.constructors.key_types):
205 kt = crypto_knowledge.KeyType(key_type)
Gilles Peskine3d778392021-02-17 15:11:05 +0100206 yield from self.test_cases_for_key_type_not_supported(kt)
Gilles Peskineaf172842021-01-27 18:24:48 +0100207 for curve_family in sorted(self.constructors.ecc_curves):
208 for constr in ('PSA_KEY_TYPE_ECC_KEY_PAIR',
209 'PSA_KEY_TYPE_ECC_PUBLIC_KEY'):
210 kt = crypto_knowledge.KeyType(constr, [curve_family])
Gilles Peskine3d778392021-02-17 15:11:05 +0100211 yield from self.test_cases_for_key_type_not_supported(
Gilles Peskineaf172842021-01-27 18:24:48 +0100212 kt, param_descr='type')
Gilles Peskine3d778392021-02-17 15:11:05 +0100213 yield from self.test_cases_for_key_type_not_supported(
Gilles Peskineaf172842021-01-27 18:24:48 +0100214 kt, 0, param_descr='curve')
Gilles Peskineb94ea512021-03-10 02:12:08 +0100215
216
Gilles Peskine897dff92021-03-10 15:03:44 +0100217class StorageKey(psa_storage.Key):
218 """Representation of a key for storage format testing."""
219
220 def __init__(self, *, description: str, **kwargs) -> None:
221 super().__init__(**kwargs)
222 self.description = description #type: str
223
224class StorageFormat:
225 """Storage format stability test cases."""
226
227 def __init__(self, info: Information, version: int, forward: bool) -> None:
228 """Prepare to generate test cases for storage format stability.
229
230 * `info`: information about the API. See the `Information` class.
231 * `version`: the storage format version to generate test cases for.
232 * `forward`: if true, generate forward compatibility test cases which
233 save a key and check that its representation is as intended. Otherwise
234 generate backward compatibility test cases which inject a key
235 representation and check that it can be read and used.
236 """
237 self.constructors = info.constructors
238 self.version = version
239 self.forward = forward
240
241 def make_test_case(self, key: StorageKey) -> test_case.TestCase:
242 """Construct a storage format test case for the given key.
243
244 If ``forward`` is true, generate a forward compatibility test case:
245 create a key and validate that it has the expected representation.
246 Otherwise generate a backward compatibility test case: inject the
247 key representation into storage and validate that it can be read
248 correctly.
249 """
250 verb = 'save' if self.forward else 'read'
251 tc = test_case.TestCase()
252 tc.set_description('PSA storage {}: {}'.format(verb, key.description))
Gilles Peskinef8223ab2021-03-10 15:07:16 +0100253 dependencies = automatic_dependencies(
254 key.lifetime.string, key.type.string,
255 key.usage.string, key.alg.string, key.alg2.string,
256 )
257 dependencies = finish_family_dependencies(dependencies, key.bits)
258 tc.set_dependencies(dependencies)
Gilles Peskine897dff92021-03-10 15:03:44 +0100259 tc.set_function('key_storage_' + verb)
260 if self.forward:
261 extra_arguments = []
262 else:
263 # Some test keys have the RAW_DATA type and attributes that don't
264 # necessarily make sense. We do this to validate numerical
265 # encodings of the attributes.
266 # Raw data keys have no useful exercise anyway so there is no
267 # loss of test coverage.
268 exercise = key.type.string != 'PSA_KEY_TYPE_RAW_DATA'
269 extra_arguments = ['1' if exercise else '0']
270 tc.set_arguments([key.lifetime.string,
271 key.type.string, str(key.bits),
272 key.usage.string, key.alg.string, key.alg2.string,
273 '"' + key.material.hex() + '"',
274 '"' + key.hex() + '"',
275 *extra_arguments])
276 return tc
277
278 def key_for_usage_flags(
279 self,
280 usage_flags: List[str],
281 short: Optional[str] = None
282 ) -> StorageKey:
283 """Construct a test key for the given key usage."""
284 usage = ' | '.join(usage_flags) if usage_flags else '0'
285 if short is None:
286 short = re.sub(r'\bPSA_KEY_USAGE_', r'', usage)
287 description = 'usage: ' + short
288 key = StorageKey(version=self.version,
289 id=1, lifetime=0x00000001,
290 type='PSA_KEY_TYPE_RAW_DATA', bits=8,
291 usage=usage, alg=0, alg2=0,
292 material=b'K',
293 description=description)
294 return key
295
296 def all_keys_for_usage_flags(self) -> Iterator[StorageKey]:
297 """Generate test keys covering usage flags."""
298 known_flags = sorted(self.constructors.key_usage_flags)
299 yield self.key_for_usage_flags(['0'])
300 for usage_flag in known_flags:
301 yield self.key_for_usage_flags([usage_flag])
302 for flag1, flag2 in zip(known_flags,
303 known_flags[1:] + [known_flags[0]]):
304 yield self.key_for_usage_flags([flag1, flag2])
305 yield self.key_for_usage_flags(known_flags, short='all known')
306
Gilles Peskinef8223ab2021-03-10 15:07:16 +0100307 def keys_for_type(
308 self,
309 key_type: str,
310 params: Optional[Iterable[str]] = None
311 ) -> Iterator[StorageKey]:
312 """Generate test keys for the given key type.
313
314 For key types that depend on a parameter (e.g. elliptic curve family),
315 `param` is the parameter to pass to the constructor. Only a single
316 parameter is supported.
317 """
318 kt = crypto_knowledge.KeyType(key_type, params)
319 for bits in kt.sizes_to_test():
320 usage_flags = 'PSA_KEY_USAGE_EXPORT'
321 alg = 0
322 alg2 = 0
323 key_material = kt.key_material(bits)
324 short_expression = re.sub(r'\bPSA_(?:KEY_TYPE|ECC_FAMILY)_',
325 r'',
326 kt.expression)
327 description = 'type: {} {}-bit'.format(short_expression, bits)
328 key = StorageKey(version=self.version,
329 id=1, lifetime=0x00000001,
330 type=kt.expression, bits=bits,
331 usage=usage_flags, alg=alg, alg2=alg2,
332 material=key_material,
333 description=description)
334 yield key
335
336 def all_keys_for_types(self) -> Iterator[StorageKey]:
337 """Generate test keys covering key types and their representations."""
338 for key_type in sorted(self.constructors.key_types):
339 yield from self.keys_for_type(key_type)
340 for key_type in sorted(self.constructors.key_types_from_curve):
341 for curve in sorted(self.constructors.ecc_curves):
342 yield from self.keys_for_type(key_type, [curve])
343 ## Diffie-Hellman (FFDH) is not supported yet, either in
344 ## crypto_knowledge.py or in Mbed TLS.
345 # for key_type in sorted(self.constructors.key_types_from_group):
346 # for group in sorted(self.constructors.dh_groups):
347 # yield from self.keys_for_type(key_type, [group])
348
Gilles Peskined86bc522021-03-10 15:08:57 +0100349 def keys_for_algorithm(self, alg: str) -> Iterator[StorageKey]:
350 """Generate test keys for the specified algorithm."""
351 # For now, we don't have information on the compatibility of key
352 # types and algorithms. So we just test the encoding of algorithms,
353 # and not that operations can be performed with them.
354 descr = alg
355 usage = 'PSA_KEY_USAGE_EXPORT'
356 key1 = StorageKey(version=self.version,
357 id=1, lifetime=0x00000001,
358 type='PSA_KEY_TYPE_RAW_DATA', bits=8,
359 usage=usage, alg=alg, alg2=0,
360 material=b'K',
361 description='alg: ' + descr)
362 yield key1
363 key2 = StorageKey(version=self.version,
364 id=1, lifetime=0x00000001,
365 type='PSA_KEY_TYPE_RAW_DATA', bits=8,
366 usage=usage, alg=0, alg2=alg,
367 material=b'L',
368 description='alg2: ' + descr)
369 yield key2
370
371 def all_keys_for_algorithms(self) -> Iterator[StorageKey]:
372 """Generate test keys covering algorithm encodings."""
373 for alg in sorted(self.constructors.algorithms):
374 yield from self.keys_for_algorithm(alg)
375 # To do: algorithm constructors with parameters
376
Gilles Peskine897dff92021-03-10 15:03:44 +0100377 def all_test_cases(self) -> Iterator[test_case.TestCase]:
378 """Generate all storage format test cases."""
379 for key in self.all_keys_for_usage_flags():
380 yield self.make_test_case(key)
Gilles Peskinef8223ab2021-03-10 15:07:16 +0100381 for key in self.all_keys_for_types():
382 yield self.make_test_case(key)
Gilles Peskined86bc522021-03-10 15:08:57 +0100383 for key in self.all_keys_for_algorithms():
384 yield self.make_test_case(key)
Gilles Peskinef8223ab2021-03-10 15:07:16 +0100385 # To do: vary id, lifetime
Gilles Peskine897dff92021-03-10 15:03:44 +0100386
387
Gilles Peskineb94ea512021-03-10 02:12:08 +0100388class TestGenerator:
389 """Generate test data."""
390
391 def __init__(self, options) -> None:
392 self.test_suite_directory = self.get_option(options, 'directory',
393 'tests/suites')
394 self.info = Information()
395
396 @staticmethod
397 def get_option(options, name: str, default: T) -> T:
398 value = getattr(options, name, None)
399 return default if value is None else value
400
Gilles Peskine0298bda2021-03-10 02:34:37 +0100401 def filename_for(self, basename: str) -> str:
402 """The location of the data file with the specified base name."""
Bence Szépkúti9e84ec72021-05-07 11:49:17 +0200403 return posixpath.join(self.test_suite_directory, basename + '.data')
Gilles Peskine0298bda2021-03-10 02:34:37 +0100404
Gilles Peskineb94ea512021-03-10 02:12:08 +0100405 def write_test_data_file(self, basename: str,
406 test_cases: Iterable[test_case.TestCase]) -> None:
407 """Write the test cases to a .data file.
408
409 The output file is ``basename + '.data'`` in the test suite directory.
410 """
Gilles Peskine0298bda2021-03-10 02:34:37 +0100411 filename = self.filename_for(basename)
Gilles Peskineb94ea512021-03-10 02:12:08 +0100412 test_case.write_data_file(filename, test_cases)
413
Gilles Peskine0298bda2021-03-10 02:34:37 +0100414 TARGETS = {
415 'test_suite_psa_crypto_not_supported.generated':
Gilles Peskine3d778392021-02-17 15:11:05 +0100416 lambda info: NotSupported(info).test_cases_for_not_supported(),
Gilles Peskine897dff92021-03-10 15:03:44 +0100417 'test_suite_psa_crypto_storage_format.current':
418 lambda info: StorageFormat(info, 0, True).all_test_cases(),
419 'test_suite_psa_crypto_storage_format.v0':
420 lambda info: StorageFormat(info, 0, False).all_test_cases(),
Gilles Peskine0298bda2021-03-10 02:34:37 +0100421 } #type: Dict[str, Callable[[Information], Iterable[test_case.TestCase]]]
422
423 def generate_target(self, name: str) -> None:
424 test_cases = self.TARGETS[name](self.info)
425 self.write_test_data_file(name, test_cases)
Gilles Peskine14e428f2021-01-26 22:19:21 +0100426
Gilles Peskine09940492021-01-26 22:16:30 +0100427def main(args):
428 """Command line entry point."""
429 parser = argparse.ArgumentParser(description=__doc__)
Gilles Peskine0298bda2021-03-10 02:34:37 +0100430 parser.add_argument('--list', action='store_true',
431 help='List available targets and exit')
432 parser.add_argument('targets', nargs='*', metavar='TARGET',
433 help='Target file to generate (default: all; "-": none)')
Gilles Peskine09940492021-01-26 22:16:30 +0100434 options = parser.parse_args(args)
Gilles Peskinec86f20a2021-04-22 00:20:47 +0200435 build_tree.chdir_to_root()
Gilles Peskine09940492021-01-26 22:16:30 +0100436 generator = TestGenerator(options)
Gilles Peskine0298bda2021-03-10 02:34:37 +0100437 if options.list:
438 for name in sorted(generator.TARGETS):
439 print(generator.filename_for(name))
440 return
441 if options.targets:
442 # Allow "-" as a special case so you can run
443 # ``generate_psa_tests.py - $targets`` and it works uniformly whether
444 # ``$targets`` is empty or not.
445 options.targets = [os.path.basename(re.sub(r'\.data\Z', r'', target))
446 for target in options.targets
447 if target != '-']
448 else:
449 options.targets = sorted(generator.TARGETS)
450 for target in options.targets:
451 generator.generate_target(target)
Gilles Peskine09940492021-01-26 22:16:30 +0100452
453if __name__ == '__main__':
454 main(sys.argv[1:])