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