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