1#!./python 2"""Run Python tests against multiple installations of OpenSSL and LibreSSL 3 4The script 5 6 (1) downloads OpenSSL / LibreSSL tar bundle 7 (2) extracts it to ./src 8 (3) compiles OpenSSL / LibreSSL 9 (4) installs OpenSSL / LibreSSL into ../multissl/$LIB/$VERSION/ 10 (5) forces a recompilation of Python modules using the 11 header and library files from ../multissl/$LIB/$VERSION/ 12 (6) runs Python's test suite 13 14The script must be run with Python's build directory as current working 15directory. 16 17The script uses LD_RUN_PATH, LD_LIBRARY_PATH, CPPFLAGS and LDFLAGS to bend 18search paths for header files and shared libraries. It's known to work on 19Linux with GCC and clang. 20 21Please keep this script compatible with Python 2.7, and 3.4 to 3.7. 22 23(c) 2013-2017 Christian Heimes <christian@python.org> 24""" 25from __future__ import print_function 26 27import argparse 28from datetime import datetime 29import logging 30import os 31try: 32 from urllib.request import urlopen 33 from urllib.error import HTTPError 34except ImportError: 35 from urllib2 import urlopen, HTTPError 36import re 37import shutil 38import string 39import subprocess 40import sys 41import tarfile 42 43 44log = logging.getLogger("multissl") 45 46OPENSSL_OLD_VERSIONS = [ 47] 48 49OPENSSL_RECENT_VERSIONS = [ 50 "1.1.1m", 51 "3.0.1" 52] 53 54LIBRESSL_OLD_VERSIONS = [ 55] 56 57LIBRESSL_RECENT_VERSIONS = [ 58] 59 60# store files in ../multissl 61HERE = os.path.dirname(os.path.abspath(__file__)) 62PYTHONROOT = os.path.abspath(os.path.join(HERE, '..', '..')) 63MULTISSL_DIR = os.path.abspath(os.path.join(PYTHONROOT, '..', 'multissl')) 64 65 66parser = argparse.ArgumentParser( 67 prog='multissl', 68 description=( 69 "Run CPython tests with multiple OpenSSL and LibreSSL " 70 "versions." 71 ) 72) 73parser.add_argument( 74 '--debug', 75 action='store_true', 76 help="Enable debug logging", 77) 78parser.add_argument( 79 '--disable-ancient', 80 action='store_true', 81 help="Don't test OpenSSL and LibreSSL versions without upstream support", 82) 83parser.add_argument( 84 '--openssl', 85 nargs='+', 86 default=(), 87 help=( 88 "OpenSSL versions, defaults to '{}' (ancient: '{}') if no " 89 "OpenSSL and LibreSSL versions are given." 90 ).format(OPENSSL_RECENT_VERSIONS, OPENSSL_OLD_VERSIONS) 91) 92parser.add_argument( 93 '--libressl', 94 nargs='+', 95 default=(), 96 help=( 97 "LibreSSL versions, defaults to '{}' (ancient: '{}') if no " 98 "OpenSSL and LibreSSL versions are given." 99 ).format(LIBRESSL_RECENT_VERSIONS, LIBRESSL_OLD_VERSIONS) 100) 101parser.add_argument( 102 '--tests', 103 nargs='*', 104 default=(), 105 help="Python tests to run, defaults to all SSL related tests.", 106) 107parser.add_argument( 108 '--base-directory', 109 default=MULTISSL_DIR, 110 help="Base directory for OpenSSL / LibreSSL sources and builds." 111) 112parser.add_argument( 113 '--no-network', 114 action='store_false', 115 dest='network', 116 help="Disable network tests." 117) 118parser.add_argument( 119 '--steps', 120 choices=['library', 'modules', 'tests'], 121 default='tests', 122 help=( 123 "Which steps to perform. 'library' downloads and compiles OpenSSL " 124 "or LibreSSL. 'module' also compiles Python modules. 'tests' builds " 125 "all and runs the test suite." 126 ) 127) 128parser.add_argument( 129 '--system', 130 default='', 131 help="Override the automatic system type detection." 132) 133parser.add_argument( 134 '--force', 135 action='store_true', 136 dest='force', 137 help="Force build and installation." 138) 139parser.add_argument( 140 '--keep-sources', 141 action='store_true', 142 dest='keep_sources', 143 help="Keep original sources for debugging." 144) 145 146 147class AbstractBuilder(object): 148 library = None 149 url_templates = None 150 src_template = None 151 build_template = None 152 depend_target = None 153 install_target = 'install' 154 jobs = os.cpu_count() 155 156 module_files = ( 157 os.path.join(PYTHONROOT, "Modules/_ssl.c"), 158 os.path.join(PYTHONROOT, "Modules/_hashopenssl.c"), 159 ) 160 module_libs = ("_ssl", "_hashlib") 161 162 def __init__(self, version, args): 163 self.version = version 164 self.args = args 165 # installation directory 166 self.install_dir = os.path.join( 167 os.path.join(args.base_directory, self.library.lower()), version 168 ) 169 # source file 170 self.src_dir = os.path.join(args.base_directory, 'src') 171 self.src_file = os.path.join( 172 self.src_dir, self.src_template.format(version)) 173 # build directory (removed after install) 174 self.build_dir = os.path.join( 175 self.src_dir, self.build_template.format(version)) 176 self.system = args.system 177 178 def __str__(self): 179 return "<{0.__class__.__name__} for {0.version}>".format(self) 180 181 def __eq__(self, other): 182 if not isinstance(other, AbstractBuilder): 183 return NotImplemented 184 return ( 185 self.library == other.library 186 and self.version == other.version 187 ) 188 189 def __hash__(self): 190 return hash((self.library, self.version)) 191 192 @property 193 def short_version(self): 194 """Short version for OpenSSL download URL""" 195 return None 196 197 @property 198 def openssl_cli(self): 199 """openssl CLI binary""" 200 return os.path.join(self.install_dir, "bin", "openssl") 201 202 @property 203 def openssl_version(self): 204 """output of 'bin/openssl version'""" 205 cmd = [self.openssl_cli, "version"] 206 return self._subprocess_output(cmd) 207 208 @property 209 def pyssl_version(self): 210 """Value of ssl.OPENSSL_VERSION""" 211 cmd = [ 212 sys.executable, 213 '-c', 'import ssl; print(ssl.OPENSSL_VERSION)' 214 ] 215 return self._subprocess_output(cmd) 216 217 @property 218 def include_dir(self): 219 return os.path.join(self.install_dir, "include") 220 221 @property 222 def lib_dir(self): 223 return os.path.join(self.install_dir, "lib") 224 225 @property 226 def has_openssl(self): 227 return os.path.isfile(self.openssl_cli) 228 229 @property 230 def has_src(self): 231 return os.path.isfile(self.src_file) 232 233 def _subprocess_call(self, cmd, env=None, **kwargs): 234 log.debug("Call '{}'".format(" ".join(cmd))) 235 return subprocess.check_call(cmd, env=env, **kwargs) 236 237 def _subprocess_output(self, cmd, env=None, **kwargs): 238 log.debug("Call '{}'".format(" ".join(cmd))) 239 if env is None: 240 env = os.environ.copy() 241 env["LD_LIBRARY_PATH"] = self.lib_dir 242 out = subprocess.check_output(cmd, env=env, **kwargs) 243 return out.strip().decode("utf-8") 244 245 def _download_src(self): 246 """Download sources""" 247 src_dir = os.path.dirname(self.src_file) 248 if not os.path.isdir(src_dir): 249 os.makedirs(src_dir) 250 data = None 251 for url_template in self.url_templates: 252 url = url_template.format(v=self.version, s=self.short_version) 253 log.info("Downloading from {}".format(url)) 254 try: 255 req = urlopen(url) 256 # KISS, read all, write all 257 data = req.read() 258 except HTTPError as e: 259 log.error( 260 "Download from {} has from failed: {}".format(url, e) 261 ) 262 else: 263 log.info("Successfully downloaded from {}".format(url)) 264 break 265 if data is None: 266 raise ValueError("All download URLs have failed") 267 log.info("Storing {}".format(self.src_file)) 268 with open(self.src_file, "wb") as f: 269 f.write(data) 270 271 def _unpack_src(self): 272 """Unpack tar.gz bundle""" 273 # cleanup 274 if os.path.isdir(self.build_dir): 275 shutil.rmtree(self.build_dir) 276 os.makedirs(self.build_dir) 277 278 tf = tarfile.open(self.src_file) 279 name = self.build_template.format(self.version) 280 base = name + '/' 281 # force extraction into build dir 282 members = tf.getmembers() 283 for member in list(members): 284 if member.name == name: 285 members.remove(member) 286 elif not member.name.startswith(base): 287 raise ValueError(member.name, base) 288 member.name = member.name[len(base):].lstrip('/') 289 log.info("Unpacking files to {}".format(self.build_dir)) 290 tf.extractall(self.build_dir, members) 291 292 def _build_src(self, config_args=()): 293 """Now build openssl""" 294 log.info("Running build in {}".format(self.build_dir)) 295 cwd = self.build_dir 296 cmd = [ 297 "./config", *config_args, 298 "shared", "--debug", 299 "--prefix={}".format(self.install_dir) 300 ] 301 # cmd.extend(["no-deprecated", "--api=1.1.0"]) 302 env = os.environ.copy() 303 # set rpath 304 env["LD_RUN_PATH"] = self.lib_dir 305 if self.system: 306 env['SYSTEM'] = self.system 307 self._subprocess_call(cmd, cwd=cwd, env=env) 308 if self.depend_target: 309 self._subprocess_call( 310 ["make", "-j1", self.depend_target], cwd=cwd, env=env 311 ) 312 self._subprocess_call(["make", f"-j{self.jobs}"], cwd=cwd, env=env) 313 314 def _make_install(self): 315 self._subprocess_call( 316 ["make", "-j1", self.install_target], 317 cwd=self.build_dir 318 ) 319 self._post_install() 320 if not self.args.keep_sources: 321 shutil.rmtree(self.build_dir) 322 323 def _post_install(self): 324 pass 325 326 def install(self): 327 log.info(self.openssl_cli) 328 if not self.has_openssl or self.args.force: 329 if not self.has_src: 330 self._download_src() 331 else: 332 log.debug("Already has src {}".format(self.src_file)) 333 self._unpack_src() 334 self._build_src() 335 self._make_install() 336 else: 337 log.info("Already has installation {}".format(self.install_dir)) 338 # validate installation 339 version = self.openssl_version 340 if self.version not in version: 341 raise ValueError(version) 342 343 def recompile_pymods(self): 344 log.warning("Using build from {}".format(self.build_dir)) 345 # force a rebuild of all modules that use OpenSSL APIs 346 for fname in self.module_files: 347 os.utime(fname, None) 348 # remove all build artefacts 349 for root, dirs, files in os.walk('build'): 350 for filename in files: 351 if filename.startswith(self.module_libs): 352 os.unlink(os.path.join(root, filename)) 353 354 # overwrite header and library search paths 355 env = os.environ.copy() 356 env["CPPFLAGS"] = "-I{}".format(self.include_dir) 357 env["LDFLAGS"] = "-L{}".format(self.lib_dir) 358 # set rpath 359 env["LD_RUN_PATH"] = self.lib_dir 360 361 log.info("Rebuilding Python modules") 362 cmd = [sys.executable, os.path.join(PYTHONROOT, "setup.py"), "build"] 363 self._subprocess_call(cmd, env=env) 364 self.check_imports() 365 366 def check_imports(self): 367 cmd = [sys.executable, "-c", "import _ssl; import _hashlib"] 368 self._subprocess_call(cmd) 369 370 def check_pyssl(self): 371 version = self.pyssl_version 372 if self.version not in version: 373 raise ValueError(version) 374 375 def run_python_tests(self, tests, network=True): 376 if not tests: 377 cmd = [ 378 sys.executable, 379 os.path.join(PYTHONROOT, 'Lib/test/ssltests.py'), 380 '-j0' 381 ] 382 elif sys.version_info < (3, 3): 383 cmd = [sys.executable, '-m', 'test.regrtest'] 384 else: 385 cmd = [sys.executable, '-m', 'test', '-j0'] 386 if network: 387 cmd.extend(['-u', 'network', '-u', 'urlfetch']) 388 cmd.extend(['-w', '-r']) 389 cmd.extend(tests) 390 self._subprocess_call(cmd, stdout=None) 391 392 393class BuildOpenSSL(AbstractBuilder): 394 library = "OpenSSL" 395 url_templates = ( 396 "https://www.openssl.org/source/openssl-{v}.tar.gz", 397 "https://www.openssl.org/source/old/{s}/openssl-{v}.tar.gz" 398 ) 399 src_template = "openssl-{}.tar.gz" 400 build_template = "openssl-{}" 401 # only install software, skip docs 402 install_target = 'install_sw' 403 depend_target = 'depend' 404 405 def _post_install(self): 406 if self.version.startswith("3.0"): 407 self._post_install_300() 408 409 def _build_src(self, config_args=()): 410 if self.version.startswith("3.0"): 411 config_args += ("enable-fips",) 412 super()._build_src(config_args) 413 414 def _post_install_300(self): 415 # create ssl/ subdir with example configs 416 # Install FIPS module 417 self._subprocess_call( 418 ["make", "-j1", "install_ssldirs", "install_fips"], 419 cwd=self.build_dir 420 ) 421 if not os.path.isdir(self.lib_dir): 422 # 3.0.0-beta2 uses lib64 on 64 bit platforms 423 lib64 = self.lib_dir + "64" 424 os.symlink(lib64, self.lib_dir) 425 426 @property 427 def short_version(self): 428 """Short version for OpenSSL download URL""" 429 mo = re.search(r"^(\d+)\.(\d+)\.(\d+)", self.version) 430 parsed = tuple(int(m) for m in mo.groups()) 431 if parsed < (1, 0, 0): 432 return "0.9.x" 433 if parsed >= (3, 0, 0): 434 # OpenSSL 3.0.0 -> /old/3.0/ 435 parsed = parsed[:2] 436 return ".".join(str(i) for i in parsed) 437 438class BuildLibreSSL(AbstractBuilder): 439 library = "LibreSSL" 440 url_templates = ( 441 "https://ftp.openbsd.org/pub/OpenBSD/LibreSSL/libressl-{v}.tar.gz", 442 ) 443 src_template = "libressl-{}.tar.gz" 444 build_template = "libressl-{}" 445 446 447def configure_make(): 448 if not os.path.isfile('Makefile'): 449 log.info('Running ./configure') 450 subprocess.check_call([ 451 './configure', '--config-cache', '--quiet', 452 '--with-pydebug' 453 ]) 454 455 log.info('Running make') 456 subprocess.check_call(['make', '--quiet']) 457 458 459def main(): 460 args = parser.parse_args() 461 if not args.openssl and not args.libressl: 462 args.openssl = list(OPENSSL_RECENT_VERSIONS) 463 args.libressl = list(LIBRESSL_RECENT_VERSIONS) 464 if not args.disable_ancient: 465 args.openssl.extend(OPENSSL_OLD_VERSIONS) 466 args.libressl.extend(LIBRESSL_OLD_VERSIONS) 467 468 logging.basicConfig( 469 level=logging.DEBUG if args.debug else logging.INFO, 470 format="*** %(levelname)s %(message)s" 471 ) 472 473 start = datetime.now() 474 475 if args.steps in {'modules', 'tests'}: 476 for name in ['setup.py', 'Modules/_ssl.c']: 477 if not os.path.isfile(os.path.join(PYTHONROOT, name)): 478 parser.error( 479 "Must be executed from CPython build dir" 480 ) 481 if not os.path.samefile('python', sys.executable): 482 parser.error( 483 "Must be executed with ./python from CPython build dir" 484 ) 485 # check for configure and run make 486 configure_make() 487 488 # download and register builder 489 builds = [] 490 491 for version in args.openssl: 492 build = BuildOpenSSL( 493 version, 494 args 495 ) 496 build.install() 497 builds.append(build) 498 499 for version in args.libressl: 500 build = BuildLibreSSL( 501 version, 502 args 503 ) 504 build.install() 505 builds.append(build) 506 507 if args.steps in {'modules', 'tests'}: 508 for build in builds: 509 try: 510 build.recompile_pymods() 511 build.check_pyssl() 512 if args.steps == 'tests': 513 build.run_python_tests( 514 tests=args.tests, 515 network=args.network, 516 ) 517 except Exception as e: 518 log.exception("%s failed", build) 519 print("{} failed: {}".format(build, e), file=sys.stderr) 520 sys.exit(2) 521 522 log.info("\n{} finished in {}".format( 523 args.steps.capitalize(), 524 datetime.now() - start 525 )) 526 print('Python: ', sys.version) 527 if args.steps == 'tests': 528 if args.tests: 529 print('Executed Tests:', ' '.join(args.tests)) 530 else: 531 print('Executed all SSL tests.') 532 533 print('OpenSSL / LibreSSL versions:') 534 for build in builds: 535 print(" * {0.library} {0.version}".format(build)) 536 537 538if __name__ == "__main__": 539 main() 540