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