1#!/usr/bin/env python3
2"""
3Purpose
4
5This script is a small wrapper around the abi-compliance-checker and
6abi-dumper tools, applying them to compare the ABI and API of the library
7files from two different Git revisions within an Mbed TLS repository.
8The results of the comparison are either formatted as HTML and stored at
9a configurable location, or are given as a brief list of problems.
10Returns 0 on success, 1 on ABI/API non-compliance, and 2 if there is an error
11while running the script. Note: must be run from Mbed TLS root.
12"""
13
14# Copyright The Mbed TLS Contributors
15# SPDX-License-Identifier: Apache-2.0
16#
17# Licensed under the Apache License, Version 2.0 (the "License"); you may
18# not use this file except in compliance with the License.
19# You may obtain a copy of the License at
20#
21# http://www.apache.org/licenses/LICENSE-2.0
22#
23# Unless required by applicable law or agreed to in writing, software
24# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
25# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26# See the License for the specific language governing permissions and
27# limitations under the License.
28
29import os
30import sys
31import traceback
32import shutil
33import subprocess
34import argparse
35import logging
36import tempfile
37import fnmatch
38from types import SimpleNamespace
39
40import xml.etree.ElementTree as ET
41
42
43class AbiChecker:
44    """API and ABI checker."""
45
46    def __init__(self, old_version, new_version, configuration):
47        """Instantiate the API/ABI checker.
48
49        old_version: RepoVersion containing details to compare against
50        new_version: RepoVersion containing details to check
51        configuration.report_dir: directory for output files
52        configuration.keep_all_reports: if false, delete old reports
53        configuration.brief: if true, output shorter report to stdout
54        configuration.skip_file: path to file containing symbols and types to skip
55        """
56        self.repo_path = "."
57        self.log = None
58        self.verbose = configuration.verbose
59        self._setup_logger()
60        self.report_dir = os.path.abspath(configuration.report_dir)
61        self.keep_all_reports = configuration.keep_all_reports
62        self.can_remove_report_dir = not (os.path.exists(self.report_dir) or
63                                          self.keep_all_reports)
64        self.old_version = old_version
65        self.new_version = new_version
66        self.skip_file = configuration.skip_file
67        self.brief = configuration.brief
68        self.git_command = "git"
69        self.make_command = "make"
70
71    @staticmethod
72    def check_repo_path():
73        if not all(os.path.isdir(d) for d in ["include", "library", "tests"]):
74            raise Exception("Must be run from Mbed TLS root")
75
76    def _setup_logger(self):
77        self.log = logging.getLogger()
78        if self.verbose:
79            self.log.setLevel(logging.DEBUG)
80        else:
81            self.log.setLevel(logging.INFO)
82        self.log.addHandler(logging.StreamHandler())
83
84    @staticmethod
85    def check_abi_tools_are_installed():
86        for command in ["abi-dumper", "abi-compliance-checker"]:
87            if not shutil.which(command):
88                raise Exception("{} not installed, aborting".format(command))
89
90    def _get_clean_worktree_for_git_revision(self, version):
91        """Make a separate worktree with version.revision checked out.
92        Do not modify the current worktree."""
93        git_worktree_path = tempfile.mkdtemp()
94        if version.repository:
95            self.log.debug(
96                "Checking out git worktree for revision {} from {}".format(
97                    version.revision, version.repository
98                )
99            )
100            fetch_output = subprocess.check_output(
101                [self.git_command, "fetch",
102                 version.repository, version.revision],
103                cwd=self.repo_path,
104                stderr=subprocess.STDOUT
105            )
106            self.log.debug(fetch_output.decode("utf-8"))
107            worktree_rev = "FETCH_HEAD"
108        else:
109            self.log.debug("Checking out git worktree for revision {}".format(
110                version.revision
111            ))
112            worktree_rev = version.revision
113        worktree_output = subprocess.check_output(
114            [self.git_command, "worktree", "add", "--detach",
115             git_worktree_path, worktree_rev],
116            cwd=self.repo_path,
117            stderr=subprocess.STDOUT
118        )
119        self.log.debug(worktree_output.decode("utf-8"))
120        version.commit = subprocess.check_output(
121            [self.git_command, "rev-parse", "HEAD"],
122            cwd=git_worktree_path,
123            stderr=subprocess.STDOUT
124        ).decode("ascii").rstrip()
125        self.log.debug("Commit is {}".format(version.commit))
126        return git_worktree_path
127
128    def _update_git_submodules(self, git_worktree_path, version):
129        """If the crypto submodule is present, initialize it.
130        if version.crypto_revision exists, update it to that revision,
131        otherwise update it to the default revision"""
132        update_output = subprocess.check_output(
133            [self.git_command, "submodule", "update", "--init", '--recursive'],
134            cwd=git_worktree_path,
135            stderr=subprocess.STDOUT
136        )
137        self.log.debug(update_output.decode("utf-8"))
138        if not (os.path.exists(os.path.join(git_worktree_path, "crypto"))
139                and version.crypto_revision):
140            return
141
142        if version.crypto_repository:
143            fetch_output = subprocess.check_output(
144                [self.git_command, "fetch", version.crypto_repository,
145                 version.crypto_revision],
146                cwd=os.path.join(git_worktree_path, "crypto"),
147                stderr=subprocess.STDOUT
148            )
149            self.log.debug(fetch_output.decode("utf-8"))
150            crypto_rev = "FETCH_HEAD"
151        else:
152            crypto_rev = version.crypto_revision
153
154        checkout_output = subprocess.check_output(
155            [self.git_command, "checkout", crypto_rev],
156            cwd=os.path.join(git_worktree_path, "crypto"),
157            stderr=subprocess.STDOUT
158        )
159        self.log.debug(checkout_output.decode("utf-8"))
160
161    def _build_shared_libraries(self, git_worktree_path, version):
162        """Build the shared libraries in the specified worktree."""
163        my_environment = os.environ.copy()
164        my_environment["CFLAGS"] = "-g -Og"
165        my_environment["SHARED"] = "1"
166        if os.path.exists(os.path.join(git_worktree_path, "crypto")):
167            my_environment["USE_CRYPTO_SUBMODULE"] = "1"
168        make_output = subprocess.check_output(
169            [self.make_command, "lib"],
170            env=my_environment,
171            cwd=git_worktree_path,
172            stderr=subprocess.STDOUT
173        )
174        self.log.debug(make_output.decode("utf-8"))
175        for root, _dirs, files in os.walk(git_worktree_path):
176            for file in fnmatch.filter(files, "*.so"):
177                version.modules[os.path.splitext(file)[0]] = (
178                    os.path.join(root, file)
179                )
180
181    @staticmethod
182    def _pretty_revision(version):
183        if version.revision == version.commit:
184            return version.revision
185        else:
186            return "{} ({})".format(version.revision, version.commit)
187
188    def _get_abi_dumps_from_shared_libraries(self, version):
189        """Generate the ABI dumps for the specified git revision.
190        The shared libraries must have been built and the module paths
191        present in version.modules."""
192        for mbed_module, module_path in version.modules.items():
193            output_path = os.path.join(
194                self.report_dir, "{}-{}-{}.dump".format(
195                    mbed_module, version.revision, version.version
196                )
197            )
198            abi_dump_command = [
199                "abi-dumper",
200                module_path,
201                "-o", output_path,
202                "-lver", self._pretty_revision(version),
203            ]
204            abi_dump_output = subprocess.check_output(
205                abi_dump_command,
206                stderr=subprocess.STDOUT
207            )
208            self.log.debug(abi_dump_output.decode("utf-8"))
209            version.abi_dumps[mbed_module] = output_path
210
211    def _cleanup_worktree(self, git_worktree_path):
212        """Remove the specified git worktree."""
213        shutil.rmtree(git_worktree_path)
214        worktree_output = subprocess.check_output(
215            [self.git_command, "worktree", "prune"],
216            cwd=self.repo_path,
217            stderr=subprocess.STDOUT
218        )
219        self.log.debug(worktree_output.decode("utf-8"))
220
221    def _get_abi_dump_for_ref(self, version):
222        """Generate the ABI dumps for the specified git revision."""
223        git_worktree_path = self._get_clean_worktree_for_git_revision(version)
224        self._update_git_submodules(git_worktree_path, version)
225        self._build_shared_libraries(git_worktree_path, version)
226        self._get_abi_dumps_from_shared_libraries(version)
227        self._cleanup_worktree(git_worktree_path)
228
229    def _remove_children_with_tag(self, parent, tag):
230        children = parent.getchildren()
231        for child in children:
232            if child.tag == tag:
233                parent.remove(child)
234            else:
235                self._remove_children_with_tag(child, tag)
236
237    def _remove_extra_detail_from_report(self, report_root):
238        for tag in ['test_info', 'test_results', 'problem_summary',
239                    'added_symbols', 'affected']:
240            self._remove_children_with_tag(report_root, tag)
241
242        for report in report_root:
243            for problems in report.getchildren()[:]:
244                if not problems.getchildren():
245                    report.remove(problems)
246
247    def _abi_compliance_command(self, mbed_module, output_path):
248        """Build the command to run to analyze the library mbed_module.
249        The report will be placed in output_path."""
250        abi_compliance_command = [
251            "abi-compliance-checker",
252            "-l", mbed_module,
253            "-old", self.old_version.abi_dumps[mbed_module],
254            "-new", self.new_version.abi_dumps[mbed_module],
255            "-strict",
256            "-report-path", output_path,
257        ]
258        if self.skip_file:
259            abi_compliance_command += ["-skip-symbols", self.skip_file,
260                                       "-skip-types", self.skip_file]
261        if self.brief:
262            abi_compliance_command += ["-report-format", "xml",
263                                       "-stdout"]
264        return abi_compliance_command
265
266    def _is_library_compatible(self, mbed_module, compatibility_report):
267        """Test if the library mbed_module has remained compatible.
268        Append a message regarding compatibility to compatibility_report."""
269        output_path = os.path.join(
270            self.report_dir, "{}-{}-{}.html".format(
271                mbed_module, self.old_version.revision,
272                self.new_version.revision
273            )
274        )
275        try:
276            subprocess.check_output(
277                self._abi_compliance_command(mbed_module, output_path),
278                stderr=subprocess.STDOUT
279            )
280        except subprocess.CalledProcessError as err:
281            if err.returncode != 1:
282                raise err
283            if self.brief:
284                self.log.info(
285                    "Compatibility issues found for {}".format(mbed_module)
286                )
287                report_root = ET.fromstring(err.output.decode("utf-8"))
288                self._remove_extra_detail_from_report(report_root)
289                self.log.info(ET.tostring(report_root).decode("utf-8"))
290            else:
291                self.can_remove_report_dir = False
292                compatibility_report.append(
293                    "Compatibility issues found for {}, "
294                    "for details see {}".format(mbed_module, output_path)
295                )
296            return False
297        compatibility_report.append(
298            "No compatibility issues for {}".format(mbed_module)
299        )
300        if not (self.keep_all_reports or self.brief):
301            os.remove(output_path)
302        return True
303
304    def get_abi_compatibility_report(self):
305        """Generate a report of the differences between the reference ABI
306        and the new ABI. ABI dumps from self.old_version and self.new_version
307        must be available."""
308        compatibility_report = ["Checking evolution from {} to {}".format(
309            self._pretty_revision(self.old_version),
310            self._pretty_revision(self.new_version)
311        )]
312        compliance_return_code = 0
313        shared_modules = list(set(self.old_version.modules.keys()) &
314                              set(self.new_version.modules.keys()))
315        for mbed_module in shared_modules:
316            if not self._is_library_compatible(mbed_module,
317                                               compatibility_report):
318                compliance_return_code = 1
319        for version in [self.old_version, self.new_version]:
320            for mbed_module, mbed_module_dump in version.abi_dumps.items():
321                os.remove(mbed_module_dump)
322        if self.can_remove_report_dir:
323            os.rmdir(self.report_dir)
324        self.log.info("\n".join(compatibility_report))
325        return compliance_return_code
326
327    def check_for_abi_changes(self):
328        """Generate a report of ABI differences
329        between self.old_rev and self.new_rev."""
330        self.check_repo_path()
331        self.check_abi_tools_are_installed()
332        self._get_abi_dump_for_ref(self.old_version)
333        self._get_abi_dump_for_ref(self.new_version)
334        return self.get_abi_compatibility_report()
335
336
337def run_main():
338    try:
339        parser = argparse.ArgumentParser(
340            description=(
341                """This script is a small wrapper around the
342                abi-compliance-checker and abi-dumper tools, applying them
343                to compare the ABI and API of the library files from two
344                different Git revisions within an Mbed TLS repository.
345                The results of the comparison are either formatted as HTML and
346                stored at a configurable location, or are given as a brief list
347                of problems. Returns 0 on success, 1 on ABI/API non-compliance,
348                and 2 if there is an error while running the script.
349                Note: must be run from Mbed TLS root."""
350            )
351        )
352        parser.add_argument(
353            "-v", "--verbose", action="store_true",
354            help="set verbosity level",
355        )
356        parser.add_argument(
357            "-r", "--report-dir", type=str, default="reports",
358            help="directory where reports are stored, default is reports",
359        )
360        parser.add_argument(
361            "-k", "--keep-all-reports", action="store_true",
362            help="keep all reports, even if there are no compatibility issues",
363        )
364        parser.add_argument(
365            "-o", "--old-rev", type=str, help="revision for old version.",
366            required=True,
367        )
368        parser.add_argument(
369            "-or", "--old-repo", type=str, help="repository for old version."
370        )
371        parser.add_argument(
372            "-oc", "--old-crypto-rev", type=str,
373            help="revision for old crypto submodule."
374        )
375        parser.add_argument(
376            "-ocr", "--old-crypto-repo", type=str,
377            help="repository for old crypto submodule."
378        )
379        parser.add_argument(
380            "-n", "--new-rev", type=str, help="revision for new version",
381            required=True,
382        )
383        parser.add_argument(
384            "-nr", "--new-repo", type=str, help="repository for new version."
385        )
386        parser.add_argument(
387            "-nc", "--new-crypto-rev", type=str,
388            help="revision for new crypto version"
389        )
390        parser.add_argument(
391            "-ncr", "--new-crypto-repo", type=str,
392            help="repository for new crypto submodule."
393        )
394        parser.add_argument(
395            "-s", "--skip-file", type=str,
396            help=("path to file containing symbols and types to skip "
397                  "(typically \"-s identifiers\" after running "
398                  "\"tests/scripts/list-identifiers.sh --internal\")")
399        )
400        parser.add_argument(
401            "-b", "--brief", action="store_true",
402            help="output only the list of issues to stdout, instead of a full report",
403        )
404        abi_args = parser.parse_args()
405        if os.path.isfile(abi_args.report_dir):
406            print("Error: {} is not a directory".format(abi_args.report_dir))
407            parser.exit()
408        old_version = SimpleNamespace(
409            version="old",
410            repository=abi_args.old_repo,
411            revision=abi_args.old_rev,
412            commit=None,
413            crypto_repository=abi_args.old_crypto_repo,
414            crypto_revision=abi_args.old_crypto_rev,
415            abi_dumps={},
416            modules={}
417        )
418        new_version = SimpleNamespace(
419            version="new",
420            repository=abi_args.new_repo,
421            revision=abi_args.new_rev,
422            commit=None,
423            crypto_repository=abi_args.new_crypto_repo,
424            crypto_revision=abi_args.new_crypto_rev,
425            abi_dumps={},
426            modules={}
427        )
428        configuration = SimpleNamespace(
429            verbose=abi_args.verbose,
430            report_dir=abi_args.report_dir,
431            keep_all_reports=abi_args.keep_all_reports,
432            brief=abi_args.brief,
433            skip_file=abi_args.skip_file
434        )
435        abi_check = AbiChecker(old_version, new_version, configuration)
436        return_code = abi_check.check_for_abi_changes()
437        sys.exit(return_code)
438    except Exception: # pylint: disable=broad-except
439        # Print the backtrace and exit explicitly so as to exit with
440        # status 2, not 1.
441        traceback.print_exc()
442        sys.exit(2)
443
444
445if __name__ == "__main__":
446    run_main()
447