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.1l",
51    "3.0.0"
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