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