Home | History | Annotate | Download | only in ssl
      1 #!./python
      2 """Run Python tests against multiple installations of OpenSSL and LibreSSL
      3 
      4 The 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 
     14 The script must be run with Python's build directory as current working
     15 directory.
     16 
     17 The script uses LD_RUN_PATH, LD_LIBRARY_PATH, CPPFLAGS and LDFLAGS to bend
     18 search paths for header files and shared libraries. It's known to work on
     19 Linux with GCC and clang.
     20 
     21 Please keep this script compatible with Python 2.7, and 3.4 to 3.7.
     22 
     23 (c) 2013-2017 Christian Heimes <christian (at] python.org>
     24 """
     25 from __future__ import print_function
     26 
     27 import argparse
     28 from datetime import datetime
     29 import logging
     30 import os
     31 try:
     32     from urllib.request import urlopen
     33 except ImportError:
     34     from urllib2 import urlopen
     35 import subprocess
     36 import shutil
     37 import sys
     38 import tarfile
     39 
     40 
     41 log = logging.getLogger("multissl")
     42 
     43 OPENSSL_OLD_VERSIONS = [
     44      "0.9.8zc",
     45      "0.9.8zh",
     46      "1.0.1u",
     47 ]
     48 
     49 OPENSSL_RECENT_VERSIONS = [
     50      "1.0.2",
     51      "1.0.2l",
     52      "1.1.0f",
     53 ]
     54 
     55 LIBRESSL_OLD_VERSIONS = [
     56     "2.3.10",
     57     "2.4.5",
     58 ]
     59 
     60 LIBRESSL_RECENT_VERSIONS = [
     61     "2.5.5",
     62     "2.6.4",
     63     "2.7.1",
     64 ]
     65 
     66 # store files in ../multissl
     67 HERE = os.path.abspath(os.getcwd())
     68 MULTISSL_DIR = os.path.abspath(os.path.join(HERE, '..', 'multissl'))
     69 
     70 parser = argparse.ArgumentParser(
     71     prog='multissl',
     72     description=(
     73         "Run CPython tests with multiple OpenSSL and LibreSSL "
     74         "versions."
     75     )
     76 )
     77 parser.add_argument(
     78     '--debug',
     79     action='store_true',
     80     help="Enable debug mode",
     81 )
     82 parser.add_argument(
     83     '--disable-ancient',
     84     action='store_true',
     85     help="Don't test OpenSSL < 1.0.2 and LibreSSL < 2.5.3.",
     86 )
     87 parser.add_argument(
     88     '--openssl',
     89     nargs='+',
     90     default=(),
     91     help=(
     92         "OpenSSL versions, defaults to '{}' (ancient: '{}') if no "
     93         "OpenSSL and LibreSSL versions are given."
     94     ).format(OPENSSL_RECENT_VERSIONS, OPENSSL_OLD_VERSIONS)
     95 )
     96 parser.add_argument(
     97     '--libressl',
     98     nargs='+',
     99     default=(),
    100     help=(
    101         "LibreSSL versions, defaults to '{}' (ancient: '{}') if no "
    102         "OpenSSL and LibreSSL versions are given."
    103     ).format(LIBRESSL_RECENT_VERSIONS, LIBRESSL_OLD_VERSIONS)
    104 )
    105 parser.add_argument(
    106     '--tests',
    107     nargs='*',
    108     default=(),
    109     help="Python tests to run, defaults to all SSL related tests.",
    110 )
    111 parser.add_argument(
    112     '--base-directory',
    113     default=MULTISSL_DIR,
    114     help="Base directory for OpenSSL / LibreSSL sources and builds."
    115 )
    116 parser.add_argument(
    117     '--no-network',
    118     action='store_false',
    119     dest='network',
    120     help="Disable network tests."
    121 )
    122 parser.add_argument(
    123     '--compile-only',
    124     action='store_true',
    125     help="Don't run tests, only compile _ssl.c and _hashopenssl.c."
    126 )
    127 
    128 
    129 class AbstractBuilder(object):
    130     library = None
    131     url_template = None
    132     src_template = None
    133     build_template = None
    134 
    135     module_files = ("Modules/_ssl.c",
    136                     "Modules/_hashopenssl.c")
    137     module_libs = ("_ssl", "_hashlib")
    138 
    139     def __init__(self, version, compile_args=(),
    140                  basedir=MULTISSL_DIR):
    141         self.version = version
    142         self.compile_args = compile_args
    143         # installation directory
    144         self.install_dir = os.path.join(
    145             os.path.join(basedir, self.library.lower()), version
    146         )
    147         # source file
    148         self.src_dir = os.path.join(basedir, 'src')
    149         self.src_file = os.path.join(
    150             self.src_dir, self.src_template.format(version))
    151         # build directory (removed after install)
    152         self.build_dir = os.path.join(
    153             self.src_dir, self.build_template.format(version))
    154 
    155     def __str__(self):
    156         return "<{0.__class__.__name__} for {0.version}>".format(self)
    157 
    158     def __eq__(self, other):
    159         if not isinstance(other, AbstractBuilder):
    160             return NotImplemented
    161         return (
    162             self.library == other.library
    163             and self.version == other.version
    164         )
    165 
    166     def __hash__(self):
    167         return hash((self.library, self.version))
    168 
    169     @property
    170     def openssl_cli(self):
    171         """openssl CLI binary"""
    172         return os.path.join(self.install_dir, "bin", "openssl")
    173 
    174     @property
    175     def openssl_version(self):
    176         """output of 'bin/openssl version'"""
    177         cmd = [self.openssl_cli, "version"]
    178         return self._subprocess_output(cmd)
    179 
    180     @property
    181     def pyssl_version(self):
    182         """Value of ssl.OPENSSL_VERSION"""
    183         cmd = [
    184             sys.executable,
    185             '-c', 'import ssl; print(ssl.OPENSSL_VERSION)'
    186         ]
    187         return self._subprocess_output(cmd)
    188 
    189     @property
    190     def include_dir(self):
    191         return os.path.join(self.install_dir, "include")
    192 
    193     @property
    194     def lib_dir(self):
    195         return os.path.join(self.install_dir, "lib")
    196 
    197     @property
    198     def has_openssl(self):
    199         return os.path.isfile(self.openssl_cli)
    200 
    201     @property
    202     def has_src(self):
    203         return os.path.isfile(self.src_file)
    204 
    205     def _subprocess_call(self, cmd, env=None, **kwargs):
    206         log.debug("Call '{}'".format(" ".join(cmd)))
    207         return subprocess.check_call(cmd, env=env, **kwargs)
    208 
    209     def _subprocess_output(self, cmd, env=None, **kwargs):
    210         log.debug("Call '{}'".format(" ".join(cmd)))
    211         if env is None:
    212             env = os.environ.copy()
    213             env["LD_LIBRARY_PATH"] = self.lib_dir
    214         out = subprocess.check_output(cmd, env=env, **kwargs)
    215         return out.strip().decode("utf-8")
    216 
    217     def _download_src(self):
    218         """Download sources"""
    219         src_dir = os.path.dirname(self.src_file)
    220         if not os.path.isdir(src_dir):
    221             os.makedirs(src_dir)
    222         url = self.url_template.format(self.version)
    223         log.info("Downloading from {}".format(url))
    224         req = urlopen(url)
    225         # KISS, read all, write all
    226         data = req.read()
    227         log.info("Storing {}".format(self.src_file))
    228         with open(self.src_file, "wb") as f:
    229             f.write(data)
    230 
    231     def _unpack_src(self):
    232         """Unpack tar.gz bundle"""
    233         # cleanup
    234         if os.path.isdir(self.build_dir):
    235             shutil.rmtree(self.build_dir)
    236         os.makedirs(self.build_dir)
    237 
    238         tf = tarfile.open(self.src_file)
    239         name = self.build_template.format(self.version)
    240         base = name + '/'
    241         # force extraction into build dir
    242         members = tf.getmembers()
    243         for member in list(members):
    244             if member.name == name:
    245                 members.remove(member)
    246             elif not member.name.startswith(base):
    247                 raise ValueError(member.name, base)
    248             member.name = member.name[len(base):].lstrip('/')
    249         log.info("Unpacking files to {}".format(self.build_dir))
    250         tf.extractall(self.build_dir, members)
    251 
    252     def _build_src(self):
    253         """Now build openssl"""
    254         log.info("Running build in {}".format(self.build_dir))
    255         cwd = self.build_dir
    256         cmd = ["./config", "shared", "--prefix={}".format(self.install_dir)]
    257         cmd.extend(self.compile_args)
    258         self._subprocess_call(cmd, cwd=cwd)
    259         # Old OpenSSL versions do not support parallel builds.
    260         self._subprocess_call(["make", "-j1"], cwd=cwd)
    261 
    262     def _make_install(self, remove=True):
    263         self._subprocess_call(["make", "-j1", "install"], cwd=self.build_dir)
    264         if remove:
    265             shutil.rmtree(self.build_dir)
    266 
    267     def install(self):
    268         log.info(self.openssl_cli)
    269         if not self.has_openssl:
    270             if not self.has_src:
    271                 self._download_src()
    272             else:
    273                 log.debug("Already has src {}".format(self.src_file))
    274             self._unpack_src()
    275             self._build_src()
    276             self._make_install()
    277         else:
    278             log.info("Already has installation {}".format(self.install_dir))
    279         # validate installation
    280         version = self.openssl_version
    281         if self.version not in version:
    282             raise ValueError(version)
    283 
    284     def recompile_pymods(self):
    285         log.warning("Using build from {}".format(self.build_dir))
    286         # force a rebuild of all modules that use OpenSSL APIs
    287         for fname in self.module_files:
    288             os.utime(fname, None)
    289         # remove all build artefacts
    290         for root, dirs, files in os.walk('build'):
    291             for filename in files:
    292                 if filename.startswith(self.module_libs):
    293                     os.unlink(os.path.join(root, filename))
    294 
    295         # overwrite header and library search paths
    296         env = os.environ.copy()
    297         env["CPPFLAGS"] = "-I{}".format(self.include_dir)
    298         env["LDFLAGS"] = "-L{}".format(self.lib_dir)
    299         # set rpath
    300         env["LD_RUN_PATH"] = self.lib_dir
    301 
    302         log.info("Rebuilding Python modules")
    303         cmd = [sys.executable, "setup.py", "build"]
    304         self._subprocess_call(cmd, env=env)
    305         self.check_imports()
    306 
    307     def check_imports(self):
    308         cmd = [sys.executable, "-c", "import _ssl; import _hashlib"]
    309         self._subprocess_call(cmd)
    310 
    311     def check_pyssl(self):
    312         version = self.pyssl_version
    313         if self.version not in version:
    314             raise ValueError(version)
    315 
    316     def run_python_tests(self, tests, network=True):
    317         if not tests:
    318             cmd = [sys.executable, 'Lib/test/ssltests.py', '-j0']
    319         elif sys.version_info < (3, 3):
    320             cmd = [sys.executable, '-m', 'test.regrtest']
    321         else:
    322             cmd = [sys.executable, '-m', 'test', '-j0']
    323         if network:
    324             cmd.extend(['-u', 'network', '-u', 'urlfetch'])
    325         cmd.extend(['-w', '-r'])
    326         cmd.extend(tests)
    327         self._subprocess_call(cmd, stdout=None)
    328 
    329 
    330 class BuildOpenSSL(AbstractBuilder):
    331     library = "OpenSSL"
    332     url_template = "https://www.openssl.org/source/openssl-{}.tar.gz"
    333     src_template = "openssl-{}.tar.gz"
    334     build_template = "openssl-{}"
    335 
    336 
    337 class BuildLibreSSL(AbstractBuilder):
    338     library = "LibreSSL"
    339     url_template = (
    340         "https://ftp.openbsd.org/pub/OpenBSD/LibreSSL/libressl-{}.tar.gz")
    341     src_template = "libressl-{}.tar.gz"
    342     build_template = "libressl-{}"
    343 
    344 
    345 def configure_make():
    346     if not os.path.isfile('Makefile'):
    347         log.info('Running ./configure')
    348         subprocess.check_call([
    349             './configure', '--config-cache', '--quiet',
    350             '--with-pydebug'
    351         ])
    352 
    353     log.info('Running make')
    354     subprocess.check_call(['make', '--quiet'])
    355 
    356 
    357 def main():
    358     args = parser.parse_args()
    359     if not args.openssl and not args.libressl:
    360         args.openssl = list(OPENSSL_RECENT_VERSIONS)
    361         args.libressl = list(LIBRESSL_RECENT_VERSIONS)
    362         if not args.disable_ancient:
    363             args.openssl.extend(OPENSSL_OLD_VERSIONS)
    364             args.libressl.extend(LIBRESSL_OLD_VERSIONS)
    365 
    366     logging.basicConfig(
    367         level=logging.DEBUG if args.debug else logging.INFO,
    368         format="*** %(levelname)s %(message)s"
    369     )
    370 
    371     start = datetime.now()
    372 
    373     for name in ['python', 'setup.py', 'Modules/_ssl.c']:
    374         if not os.path.isfile(name):
    375             parser.error(
    376                 "Must be executed from CPython build dir"
    377             )
    378     if not os.path.samefile('python', sys.executable):
    379         parser.error(
    380             "Must be executed with ./python from CPython build dir"
    381         )
    382 
    383     # check for configure and run make
    384     configure_make()
    385 
    386     # download and register builder
    387     builds = []
    388 
    389     for version in args.openssl:
    390         build = BuildOpenSSL(version)
    391         build.install()
    392         builds.append(build)
    393 
    394     for version in args.libressl:
    395         build = BuildLibreSSL(version)
    396         build.install()
    397         builds.append(build)
    398 
    399     for build in builds:
    400         try:
    401             build.recompile_pymods()
    402             build.check_pyssl()
    403             if not args.compile_only:
    404                 build.run_python_tests(
    405                     tests=args.tests,
    406                     network=args.network,
    407                 )
    408         except Exception as e:
    409             log.exception("%s failed", build)
    410             print("{} failed: {}".format(build, e), file=sys.stderr)
    411             sys.exit(2)
    412 
    413     print("\n{} finished in {}".format(
    414         "Tests" if not args.compile_only else "Builds",
    415         datetime.now() - start
    416     ))
    417     print('Python: ', sys.version)
    418     if args.compile_only:
    419         print('Build only')
    420     elif args.tests:
    421         print('Executed Tests:', ' '.join(args.tests))
    422     else:
    423         print('Executed all SSL tests.')
    424 
    425     print('OpenSSL / LibreSSL versions:')
    426     for build in builds:
    427         print("    * {0.library} {0.version}".format(build))
    428 
    429 
    430 if __name__ == "__main__":
    431     main()
    432