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