1a8e1175bSopenharmony_ci#!/usr/bin/env python3
2a8e1175bSopenharmony_ci
3a8e1175bSopenharmony_ci"""Sanity checks for test data.
4a8e1175bSopenharmony_ci
5a8e1175bSopenharmony_ciThis program contains a class for traversing test cases that can be used
6a8e1175bSopenharmony_ciindependently of the checks.
7a8e1175bSopenharmony_ci"""
8a8e1175bSopenharmony_ci
9a8e1175bSopenharmony_ci# Copyright The Mbed TLS Contributors
10a8e1175bSopenharmony_ci# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
11a8e1175bSopenharmony_ci
12a8e1175bSopenharmony_ciimport argparse
13a8e1175bSopenharmony_ciimport glob
14a8e1175bSopenharmony_ciimport os
15a8e1175bSopenharmony_ciimport re
16a8e1175bSopenharmony_ciimport subprocess
17a8e1175bSopenharmony_ciimport sys
18a8e1175bSopenharmony_ci
19a8e1175bSopenharmony_ciclass ScriptOutputError(ValueError):
20a8e1175bSopenharmony_ci    """A kind of ValueError that indicates we found
21a8e1175bSopenharmony_ci    the script doesn't list test cases in an expected
22a8e1175bSopenharmony_ci    pattern.
23a8e1175bSopenharmony_ci    """
24a8e1175bSopenharmony_ci
25a8e1175bSopenharmony_ci    @property
26a8e1175bSopenharmony_ci    def script_name(self):
27a8e1175bSopenharmony_ci        return super().args[0]
28a8e1175bSopenharmony_ci
29a8e1175bSopenharmony_ci    @property
30a8e1175bSopenharmony_ci    def idx(self):
31a8e1175bSopenharmony_ci        return super().args[1]
32a8e1175bSopenharmony_ci
33a8e1175bSopenharmony_ci    @property
34a8e1175bSopenharmony_ci    def line(self):
35a8e1175bSopenharmony_ci        return super().args[2]
36a8e1175bSopenharmony_ci
37a8e1175bSopenharmony_ciclass Results:
38a8e1175bSopenharmony_ci    """Store file and line information about errors or warnings in test suites."""
39a8e1175bSopenharmony_ci
40a8e1175bSopenharmony_ci    def __init__(self, options):
41a8e1175bSopenharmony_ci        self.errors = 0
42a8e1175bSopenharmony_ci        self.warnings = 0
43a8e1175bSopenharmony_ci        self.ignore_warnings = options.quiet
44a8e1175bSopenharmony_ci
45a8e1175bSopenharmony_ci    def error(self, file_name, line_number, fmt, *args):
46a8e1175bSopenharmony_ci        sys.stderr.write(('{}:{}:ERROR:' + fmt + '\n').
47a8e1175bSopenharmony_ci                         format(file_name, line_number, *args))
48a8e1175bSopenharmony_ci        self.errors += 1
49a8e1175bSopenharmony_ci
50a8e1175bSopenharmony_ci    def warning(self, file_name, line_number, fmt, *args):
51a8e1175bSopenharmony_ci        if not self.ignore_warnings:
52a8e1175bSopenharmony_ci            sys.stderr.write(('{}:{}:Warning:' + fmt + '\n')
53a8e1175bSopenharmony_ci                             .format(file_name, line_number, *args))
54a8e1175bSopenharmony_ci            self.warnings += 1
55a8e1175bSopenharmony_ci
56a8e1175bSopenharmony_ciclass TestDescriptionExplorer:
57a8e1175bSopenharmony_ci    """An iterator over test cases with descriptions.
58a8e1175bSopenharmony_ci
59a8e1175bSopenharmony_ciThe test cases that have descriptions are:
60a8e1175bSopenharmony_ci* Individual unit tests (entries in a .data file) in test suites.
61a8e1175bSopenharmony_ci* Individual test cases in ssl-opt.sh.
62a8e1175bSopenharmony_ci
63a8e1175bSopenharmony_ciThis is an abstract class. To use it, derive a class that implements
64a8e1175bSopenharmony_cithe process_test_case method, and call walk_all().
65a8e1175bSopenharmony_ci"""
66a8e1175bSopenharmony_ci
67a8e1175bSopenharmony_ci    def process_test_case(self, per_file_state,
68a8e1175bSopenharmony_ci                          file_name, line_number, description):
69a8e1175bSopenharmony_ci        """Process a test case.
70a8e1175bSopenharmony_ci
71a8e1175bSopenharmony_ciper_file_state: an object created by new_per_file_state() at the beginning
72a8e1175bSopenharmony_ci                of each file.
73a8e1175bSopenharmony_cifile_name: a relative path to the file containing the test case.
74a8e1175bSopenharmony_ciline_number: the line number in the given file.
75a8e1175bSopenharmony_cidescription: the test case description as a byte string.
76a8e1175bSopenharmony_ci"""
77a8e1175bSopenharmony_ci        raise NotImplementedError
78a8e1175bSopenharmony_ci
79a8e1175bSopenharmony_ci    def new_per_file_state(self):
80a8e1175bSopenharmony_ci        """Return a new per-file state object.
81a8e1175bSopenharmony_ci
82a8e1175bSopenharmony_ciThe default per-file state object is None. Child classes that require per-file
83a8e1175bSopenharmony_cistate may override this method.
84a8e1175bSopenharmony_ci"""
85a8e1175bSopenharmony_ci        #pylint: disable=no-self-use
86a8e1175bSopenharmony_ci        return None
87a8e1175bSopenharmony_ci
88a8e1175bSopenharmony_ci    def walk_test_suite(self, data_file_name):
89a8e1175bSopenharmony_ci        """Iterate over the test cases in the given unit test data file."""
90a8e1175bSopenharmony_ci        in_paragraph = False
91a8e1175bSopenharmony_ci        descriptions = self.new_per_file_state() # pylint: disable=assignment-from-none
92a8e1175bSopenharmony_ci        with open(data_file_name, 'rb') as data_file:
93a8e1175bSopenharmony_ci            for line_number, line in enumerate(data_file, 1):
94a8e1175bSopenharmony_ci                line = line.rstrip(b'\r\n')
95a8e1175bSopenharmony_ci                if not line:
96a8e1175bSopenharmony_ci                    in_paragraph = False
97a8e1175bSopenharmony_ci                    continue
98a8e1175bSopenharmony_ci                if line.startswith(b'#'):
99a8e1175bSopenharmony_ci                    continue
100a8e1175bSopenharmony_ci                if not in_paragraph:
101a8e1175bSopenharmony_ci                    # This is a test case description line.
102a8e1175bSopenharmony_ci                    self.process_test_case(descriptions,
103a8e1175bSopenharmony_ci                                           data_file_name, line_number, line)
104a8e1175bSopenharmony_ci                in_paragraph = True
105a8e1175bSopenharmony_ci
106a8e1175bSopenharmony_ci    def collect_from_script(self, script_name):
107a8e1175bSopenharmony_ci        """Collect the test cases in a script by calling its listing test cases
108a8e1175bSopenharmony_cioption"""
109a8e1175bSopenharmony_ci        descriptions = self.new_per_file_state() # pylint: disable=assignment-from-none
110a8e1175bSopenharmony_ci        listed = subprocess.check_output(['sh', script_name, '--list-test-cases'])
111a8e1175bSopenharmony_ci        # Assume test file is responsible for printing identical format of
112a8e1175bSopenharmony_ci        # test case description between --list-test-cases and its OUTCOME.CSV
113a8e1175bSopenharmony_ci        #
114a8e1175bSopenharmony_ci        # idx indicates the number of test case since there is no line number
115a8e1175bSopenharmony_ci        # in the script for each test case.
116a8e1175bSopenharmony_ci        for idx, line in enumerate(listed.splitlines()):
117a8e1175bSopenharmony_ci            # We are expecting the script to list the test cases in
118a8e1175bSopenharmony_ci            # `<suite_name>;<description>` pattern.
119a8e1175bSopenharmony_ci            script_outputs = line.split(b';', 1)
120a8e1175bSopenharmony_ci            if len(script_outputs) == 2:
121a8e1175bSopenharmony_ci                suite_name, description = script_outputs
122a8e1175bSopenharmony_ci            else:
123a8e1175bSopenharmony_ci                raise ScriptOutputError(script_name, idx, line.decode("utf-8"))
124a8e1175bSopenharmony_ci
125a8e1175bSopenharmony_ci            self.process_test_case(descriptions,
126a8e1175bSopenharmony_ci                                   suite_name.decode('utf-8'),
127a8e1175bSopenharmony_ci                                   idx,
128a8e1175bSopenharmony_ci                                   description.rstrip())
129a8e1175bSopenharmony_ci
130a8e1175bSopenharmony_ci    @staticmethod
131a8e1175bSopenharmony_ci    def collect_test_directories():
132a8e1175bSopenharmony_ci        """Get the relative path for the TLS and Crypto test directories."""
133a8e1175bSopenharmony_ci        if os.path.isdir('tests'):
134a8e1175bSopenharmony_ci            tests_dir = 'tests'
135a8e1175bSopenharmony_ci        elif os.path.isdir('suites'):
136a8e1175bSopenharmony_ci            tests_dir = '.'
137a8e1175bSopenharmony_ci        elif os.path.isdir('../suites'):
138a8e1175bSopenharmony_ci            tests_dir = '..'
139a8e1175bSopenharmony_ci        directories = [tests_dir]
140a8e1175bSopenharmony_ci        return directories
141a8e1175bSopenharmony_ci
142a8e1175bSopenharmony_ci    def walk_all(self):
143a8e1175bSopenharmony_ci        """Iterate over all named test cases."""
144a8e1175bSopenharmony_ci        test_directories = self.collect_test_directories()
145a8e1175bSopenharmony_ci        for directory in test_directories:
146a8e1175bSopenharmony_ci            for data_file_name in glob.glob(os.path.join(directory, 'suites',
147a8e1175bSopenharmony_ci                                                         '*.data')):
148a8e1175bSopenharmony_ci                self.walk_test_suite(data_file_name)
149a8e1175bSopenharmony_ci
150a8e1175bSopenharmony_ci            for sh_file in ['ssl-opt.sh', 'compat.sh']:
151a8e1175bSopenharmony_ci                sh_file = os.path.join(directory, sh_file)
152a8e1175bSopenharmony_ci                self.collect_from_script(sh_file)
153a8e1175bSopenharmony_ci
154a8e1175bSopenharmony_ciclass TestDescriptions(TestDescriptionExplorer):
155a8e1175bSopenharmony_ci    """Collect the available test cases."""
156a8e1175bSopenharmony_ci
157a8e1175bSopenharmony_ci    def __init__(self):
158a8e1175bSopenharmony_ci        super().__init__()
159a8e1175bSopenharmony_ci        self.descriptions = set()
160a8e1175bSopenharmony_ci
161a8e1175bSopenharmony_ci    def process_test_case(self, _per_file_state,
162a8e1175bSopenharmony_ci                          file_name, _line_number, description):
163a8e1175bSopenharmony_ci        """Record an available test case."""
164a8e1175bSopenharmony_ci        base_name = re.sub(r'\.[^.]*$', '', re.sub(r'.*/', '', file_name))
165a8e1175bSopenharmony_ci        key = ';'.join([base_name, description.decode('utf-8')])
166a8e1175bSopenharmony_ci        self.descriptions.add(key)
167a8e1175bSopenharmony_ci
168a8e1175bSopenharmony_cidef collect_available_test_cases():
169a8e1175bSopenharmony_ci    """Collect the available test cases."""
170a8e1175bSopenharmony_ci    explorer = TestDescriptions()
171a8e1175bSopenharmony_ci    explorer.walk_all()
172a8e1175bSopenharmony_ci    return sorted(explorer.descriptions)
173a8e1175bSopenharmony_ci
174a8e1175bSopenharmony_ciclass DescriptionChecker(TestDescriptionExplorer):
175a8e1175bSopenharmony_ci    """Check all test case descriptions.
176a8e1175bSopenharmony_ci
177a8e1175bSopenharmony_ci* Check that each description is valid (length, allowed character set, etc.).
178a8e1175bSopenharmony_ci* Check that there is no duplicated description inside of one test suite.
179a8e1175bSopenharmony_ci"""
180a8e1175bSopenharmony_ci
181a8e1175bSopenharmony_ci    def __init__(self, results):
182a8e1175bSopenharmony_ci        self.results = results
183a8e1175bSopenharmony_ci
184a8e1175bSopenharmony_ci    def new_per_file_state(self):
185a8e1175bSopenharmony_ci        """Dictionary mapping descriptions to their line number."""
186a8e1175bSopenharmony_ci        return {}
187a8e1175bSopenharmony_ci
188a8e1175bSopenharmony_ci    def process_test_case(self, per_file_state,
189a8e1175bSopenharmony_ci                          file_name, line_number, description):
190a8e1175bSopenharmony_ci        """Check test case descriptions for errors."""
191a8e1175bSopenharmony_ci        results = self.results
192a8e1175bSopenharmony_ci        seen = per_file_state
193a8e1175bSopenharmony_ci        if description in seen:
194a8e1175bSopenharmony_ci            results.error(file_name, line_number,
195a8e1175bSopenharmony_ci                          'Duplicate description (also line {})',
196a8e1175bSopenharmony_ci                          seen[description])
197a8e1175bSopenharmony_ci            return
198a8e1175bSopenharmony_ci        if re.search(br'[\t;]', description):
199a8e1175bSopenharmony_ci            results.error(file_name, line_number,
200a8e1175bSopenharmony_ci                          'Forbidden character \'{}\' in description',
201a8e1175bSopenharmony_ci                          re.search(br'[\t;]', description).group(0).decode('ascii'))
202a8e1175bSopenharmony_ci        if re.search(br'[^ -~]', description):
203a8e1175bSopenharmony_ci            results.error(file_name, line_number,
204a8e1175bSopenharmony_ci                          'Non-ASCII character in description')
205a8e1175bSopenharmony_ci        if len(description) > 66:
206a8e1175bSopenharmony_ci            results.warning(file_name, line_number,
207a8e1175bSopenharmony_ci                            'Test description too long ({} > 66)',
208a8e1175bSopenharmony_ci                            len(description))
209a8e1175bSopenharmony_ci        seen[description] = line_number
210a8e1175bSopenharmony_ci
211a8e1175bSopenharmony_cidef main():
212a8e1175bSopenharmony_ci    parser = argparse.ArgumentParser(description=__doc__)
213a8e1175bSopenharmony_ci    parser.add_argument('--list-all',
214a8e1175bSopenharmony_ci                        action='store_true',
215a8e1175bSopenharmony_ci                        help='List all test cases, without doing checks')
216a8e1175bSopenharmony_ci    parser.add_argument('--quiet', '-q',
217a8e1175bSopenharmony_ci                        action='store_true',
218a8e1175bSopenharmony_ci                        help='Hide warnings')
219a8e1175bSopenharmony_ci    parser.add_argument('--verbose', '-v',
220a8e1175bSopenharmony_ci                        action='store_false', dest='quiet',
221a8e1175bSopenharmony_ci                        help='Show warnings (default: on; undoes --quiet)')
222a8e1175bSopenharmony_ci    options = parser.parse_args()
223a8e1175bSopenharmony_ci    if options.list_all:
224a8e1175bSopenharmony_ci        descriptions = collect_available_test_cases()
225a8e1175bSopenharmony_ci        sys.stdout.write('\n'.join(descriptions + ['']))
226a8e1175bSopenharmony_ci        return
227a8e1175bSopenharmony_ci    results = Results(options)
228a8e1175bSopenharmony_ci    checker = DescriptionChecker(results)
229a8e1175bSopenharmony_ci    try:
230a8e1175bSopenharmony_ci        checker.walk_all()
231a8e1175bSopenharmony_ci    except ScriptOutputError as e:
232a8e1175bSopenharmony_ci        results.error(e.script_name, e.idx,
233a8e1175bSopenharmony_ci                      '"{}" should be listed as "<suite_name>;<description>"',
234a8e1175bSopenharmony_ci                      e.line)
235a8e1175bSopenharmony_ci    if (results.warnings or results.errors) and not options.quiet:
236a8e1175bSopenharmony_ci        sys.stderr.write('{}: {} errors, {} warnings\n'
237a8e1175bSopenharmony_ci                         .format(sys.argv[0], results.errors, results.warnings))
238a8e1175bSopenharmony_ci    sys.exit(1 if results.errors else 0)
239a8e1175bSopenharmony_ci
240a8e1175bSopenharmony_ciif __name__ == '__main__':
241a8e1175bSopenharmony_ci    main()
242