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
33except ImportError:
34    from urllib2 import urlopen
35import subprocess
36import shutil
37import sys
38import tarfile
39
40
41log = logging.getLogger("multissl")
42
43OPENSSL_OLD_VERSIONS = [
44    "1.0.2",
45]
46
47OPENSSL_RECENT_VERSIONS = [
48    "1.0.2p",
49    "1.1.0i",
50    "1.1.1",
51]
52
53LIBRESSL_OLD_VERSIONS = [
54]
55
56LIBRESSL_RECENT_VERSIONS = [
57    "2.7.4",
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 < 1.0.2 and LibreSSL < 2.5.3.",
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_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
360class 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
369class 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
377def 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
389def 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
468if __name__ == "__main__":
469    main()
470