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