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