• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 subprocess
39import sys
40import tarfile
41
42
43log = logging.getLogger("multissl")
44
45OPENSSL_OLD_VERSIONS = [
46    "1.1.1w",
47]
48
49OPENSSL_RECENT_VERSIONS = [
50    "3.0.15",
51    "3.1.7",
52    "3.2.3",
53    "3.3.2",
54]
55
56LIBRESSL_OLD_VERSIONS = [
57]
58
59LIBRESSL_RECENT_VERSIONS = [
60]
61
62# store files in ../multissl
63HERE = os.path.dirname(os.path.abspath(__file__))
64PYTHONROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
65MULTISSL_DIR = os.path.abspath(os.path.join(PYTHONROOT, '..', 'multissl'))
66
67
68parser = argparse.ArgumentParser(
69    prog='multissl',
70    description=(
71        "Run CPython tests with multiple OpenSSL and LibreSSL "
72        "versions."
73    )
74)
75parser.add_argument(
76    '--debug',
77    action='store_true',
78    help="Enable debug logging",
79)
80parser.add_argument(
81    '--disable-ancient',
82    action='store_true',
83    help="Don't test OpenSSL and LibreSSL versions without upstream support",
84)
85parser.add_argument(
86    '--openssl',
87    nargs='+',
88    default=(),
89    help=(
90        "OpenSSL versions, defaults to '{}' (ancient: '{}') if no "
91        "OpenSSL and LibreSSL versions are given."
92    ).format(OPENSSL_RECENT_VERSIONS, OPENSSL_OLD_VERSIONS)
93)
94parser.add_argument(
95    '--libressl',
96    nargs='+',
97    default=(),
98    help=(
99        "LibreSSL versions, defaults to '{}' (ancient: '{}') if no "
100        "OpenSSL and LibreSSL versions are given."
101    ).format(LIBRESSL_RECENT_VERSIONS, LIBRESSL_OLD_VERSIONS)
102)
103parser.add_argument(
104    '--tests',
105    nargs='*',
106    default=(),
107    help="Python tests to run, defaults to all SSL related tests.",
108)
109parser.add_argument(
110    '--base-directory',
111    default=MULTISSL_DIR,
112    help="Base directory for OpenSSL / LibreSSL sources and builds."
113)
114parser.add_argument(
115    '--no-network',
116    action='store_false',
117    dest='network',
118    help="Disable network tests."
119)
120parser.add_argument(
121    '--steps',
122    choices=['library', 'modules', 'tests'],
123    default='tests',
124    help=(
125        "Which steps to perform. 'library' downloads and compiles OpenSSL "
126        "or LibreSSL. 'module' also compiles Python modules. 'tests' builds "
127        "all and runs the test suite."
128    )
129)
130parser.add_argument(
131    '--system',
132    default='',
133    help="Override the automatic system type detection."
134)
135parser.add_argument(
136    '--force',
137    action='store_true',
138    dest='force',
139    help="Force build and installation."
140)
141parser.add_argument(
142    '--keep-sources',
143    action='store_true',
144    dest='keep_sources',
145    help="Keep original sources for debugging."
146)
147
148
149class AbstractBuilder(object):
150    library = None
151    url_templates = None
152    src_template = None
153    build_template = None
154    depend_target = None
155    install_target = 'install'
156    if hasattr(os, 'process_cpu_count'):
157        jobs = os.process_cpu_count()
158    else:
159        jobs = os.cpu_count()
160
161    module_files = (
162        os.path.join(PYTHONROOT, "Modules/_ssl.c"),
163        os.path.join(PYTHONROOT, "Modules/_hashopenssl.c"),
164    )
165    module_libs = ("_ssl", "_hashlib")
166
167    def __init__(self, version, args):
168        self.version = version
169        self.args = args
170        # installation directory
171        self.install_dir = os.path.join(
172            os.path.join(args.base_directory, self.library.lower()), version
173        )
174        # source file
175        self.src_dir = os.path.join(args.base_directory, 'src')
176        self.src_file = os.path.join(
177            self.src_dir, self.src_template.format(version))
178        # build directory (removed after install)
179        self.build_dir = os.path.join(
180            self.src_dir, self.build_template.format(version))
181        self.system = args.system
182
183    def __str__(self):
184        return "<{0.__class__.__name__} for {0.version}>".format(self)
185
186    def __eq__(self, other):
187        if not isinstance(other, AbstractBuilder):
188            return NotImplemented
189        return (
190            self.library == other.library
191            and self.version == other.version
192        )
193
194    def __hash__(self):
195        return hash((self.library, self.version))
196
197    @property
198    def short_version(self):
199        """Short version for OpenSSL download URL"""
200        return None
201
202    @property
203    def openssl_cli(self):
204        """openssl CLI binary"""
205        return os.path.join(self.install_dir, "bin", "openssl")
206
207    @property
208    def openssl_version(self):
209        """output of 'bin/openssl version'"""
210        cmd = [self.openssl_cli, "version"]
211        return self._subprocess_output(cmd)
212
213    @property
214    def pyssl_version(self):
215        """Value of ssl.OPENSSL_VERSION"""
216        cmd = [
217            sys.executable,
218            '-c', 'import ssl; print(ssl.OPENSSL_VERSION)'
219        ]
220        return self._subprocess_output(cmd)
221
222    @property
223    def include_dir(self):
224        return os.path.join(self.install_dir, "include")
225
226    @property
227    def lib_dir(self):
228        return os.path.join(self.install_dir, "lib")
229
230    @property
231    def has_openssl(self):
232        return os.path.isfile(self.openssl_cli)
233
234    @property
235    def has_src(self):
236        return os.path.isfile(self.src_file)
237
238    def _subprocess_call(self, cmd, env=None, **kwargs):
239        log.debug("Call '{}'".format(" ".join(cmd)))
240        return subprocess.check_call(cmd, env=env, **kwargs)
241
242    def _subprocess_output(self, cmd, env=None, **kwargs):
243        log.debug("Call '{}'".format(" ".join(cmd)))
244        if env is None:
245            env = os.environ.copy()
246            env["LD_LIBRARY_PATH"] = self.lib_dir
247        out = subprocess.check_output(cmd, env=env, **kwargs)
248        return out.strip().decode("utf-8")
249
250    def _download_src(self):
251        """Download sources"""
252        src_dir = os.path.dirname(self.src_file)
253        if not os.path.isdir(src_dir):
254            os.makedirs(src_dir)
255        data = None
256        for url_template in self.url_templates:
257            url = url_template.format(v=self.version, s=self.short_version)
258            log.info("Downloading from {}".format(url))
259            try:
260                req = urlopen(url)
261                # KISS, read all, write all
262                data = req.read()
263            except HTTPError as e:
264                log.error(
265                    "Download from {} has from failed: {}".format(url, e)
266                )
267            else:
268                log.info("Successfully downloaded from {}".format(url))
269                break
270        if data is None:
271            raise ValueError("All download URLs have failed")
272        log.info("Storing {}".format(self.src_file))
273        with open(self.src_file, "wb") as f:
274            f.write(data)
275
276    def _unpack_src(self):
277        """Unpack tar.gz bundle"""
278        # cleanup
279        if os.path.isdir(self.build_dir):
280            shutil.rmtree(self.build_dir)
281        os.makedirs(self.build_dir)
282
283        tf = tarfile.open(self.src_file)
284        name = self.build_template.format(self.version)
285        base = name + '/'
286        # force extraction into build dir
287        members = tf.getmembers()
288        for member in list(members):
289            if member.name == name:
290                members.remove(member)
291            elif not member.name.startswith(base):
292                raise ValueError(member.name, base)
293            member.name = member.name[len(base):].lstrip('/')
294        log.info("Unpacking files to {}".format(self.build_dir))
295        tf.extractall(self.build_dir, members)
296
297    def _build_src(self, config_args=()):
298        """Now build openssl"""
299        log.info("Running build in {}".format(self.build_dir))
300        cwd = self.build_dir
301        cmd = [
302            "./config", *config_args,
303            "shared", "--debug",
304            "--prefix={}".format(self.install_dir)
305        ]
306        # cmd.extend(["no-deprecated", "--api=1.1.0"])
307        env = os.environ.copy()
308        # set rpath
309        env["LD_RUN_PATH"] = self.lib_dir
310        if self.system:
311            env['SYSTEM'] = self.system
312        self._subprocess_call(cmd, cwd=cwd, env=env)
313        if self.depend_target:
314            self._subprocess_call(
315                ["make", "-j1", self.depend_target], cwd=cwd, env=env
316            )
317        self._subprocess_call(["make", f"-j{self.jobs}"], cwd=cwd, env=env)
318
319    def _make_install(self):
320        self._subprocess_call(
321            ["make", "-j1", self.install_target],
322            cwd=self.build_dir
323        )
324        self._post_install()
325        if not self.args.keep_sources:
326            shutil.rmtree(self.build_dir)
327
328    def _post_install(self):
329        pass
330
331    def install(self):
332        log.info(self.openssl_cli)
333        if not self.has_openssl or self.args.force:
334            if not self.has_src:
335                self._download_src()
336            else:
337                log.debug("Already has src {}".format(self.src_file))
338            self._unpack_src()
339            self._build_src()
340            self._make_install()
341        else:
342            log.info("Already has installation {}".format(self.install_dir))
343        # validate installation
344        version = self.openssl_version
345        if self.version not in version:
346            raise ValueError(version)
347
348    def recompile_pymods(self):
349        log.warning("Using build from {}".format(self.build_dir))
350        # force a rebuild of all modules that use OpenSSL APIs
351        for fname in self.module_files:
352            os.utime(fname, None)
353        # remove all build artefacts
354        for root, dirs, files in os.walk('build'):
355            for filename in files:
356                if filename.startswith(self.module_libs):
357                    os.unlink(os.path.join(root, filename))
358
359        # overwrite header and library search paths
360        env = os.environ.copy()
361        env["CPPFLAGS"] = "-I{}".format(self.include_dir)
362        env["LDFLAGS"] = "-L{}".format(self.lib_dir)
363        # set rpath
364        env["LD_RUN_PATH"] = self.lib_dir
365
366        log.info("Rebuilding Python modules")
367        cmd = ["make", "sharedmods", "checksharedmods"]
368        self._subprocess_call(cmd, env=env)
369        self.check_imports()
370
371    def check_imports(self):
372        cmd = [sys.executable, "-c", "import _ssl; import _hashlib"]
373        self._subprocess_call(cmd)
374
375    def check_pyssl(self):
376        version = self.pyssl_version
377        if self.version not in version:
378            raise ValueError(version)
379
380    def run_python_tests(self, tests, network=True):
381        if not tests:
382            cmd = [
383                sys.executable,
384                os.path.join(PYTHONROOT, 'Lib/test/ssltests.py'),
385                '-j0'
386            ]
387        elif sys.version_info < (3, 3):
388            cmd = [sys.executable, '-m', 'test.regrtest']
389        else:
390            cmd = [sys.executable, '-m', 'test', '-j0']
391        if network:
392            cmd.extend(['-u', 'network', '-u', 'urlfetch'])
393        cmd.extend(['-w', '-r'])
394        cmd.extend(tests)
395        self._subprocess_call(cmd, stdout=None)
396
397
398class BuildOpenSSL(AbstractBuilder):
399    library = "OpenSSL"
400    url_templates = (
401        "https://github.com/openssl/openssl/releases/download/openssl-{v}/openssl-{v}.tar.gz",
402        "https://www.openssl.org/source/openssl-{v}.tar.gz",
403        "https://www.openssl.org/source/old/{s}/openssl-{v}.tar.gz"
404    )
405    src_template = "openssl-{}.tar.gz"
406    build_template = "openssl-{}"
407    # only install software, skip docs
408    install_target = 'install_sw'
409    depend_target = 'depend'
410
411    def _post_install(self):
412        if self.version.startswith("3."):
413            self._post_install_3xx()
414
415    def _build_src(self, config_args=()):
416        if self.version.startswith("3."):
417            config_args += ("enable-fips",)
418        super()._build_src(config_args)
419
420    def _post_install_3xx(self):
421        # create ssl/ subdir with example configs
422        # Install FIPS module
423        self._subprocess_call(
424            ["make", "-j1", "install_ssldirs", "install_fips"],
425            cwd=self.build_dir
426        )
427        if not os.path.isdir(self.lib_dir):
428            # 3.0.0-beta2 uses lib64 on 64 bit platforms
429            lib64 = self.lib_dir + "64"
430            os.symlink(lib64, self.lib_dir)
431
432    @property
433    def short_version(self):
434        """Short version for OpenSSL download URL"""
435        mo = re.search(r"^(\d+)\.(\d+)\.(\d+)", self.version)
436        parsed = tuple(int(m) for m in mo.groups())
437        if parsed < (1, 0, 0):
438            return "0.9.x"
439        if parsed >= (3, 0, 0):
440            # OpenSSL 3.0.0 -> /old/3.0/
441            parsed = parsed[:2]
442        return ".".join(str(i) for i in parsed)
443
444
445class BuildLibreSSL(AbstractBuilder):
446    library = "LibreSSL"
447    url_templates = (
448        "https://ftp.openbsd.org/pub/OpenBSD/LibreSSL/libressl-{v}.tar.gz",
449    )
450    src_template = "libressl-{}.tar.gz"
451    build_template = "libressl-{}"
452
453
454def configure_make():
455    if not os.path.isfile('Makefile'):
456        log.info('Running ./configure')
457        subprocess.check_call([
458            './configure', '--config-cache', '--quiet',
459            '--with-pydebug'
460        ])
461
462    log.info('Running make')
463    subprocess.check_call(['make', '--quiet'])
464
465
466def main():
467    args = parser.parse_args()
468    if not args.openssl and not args.libressl:
469        args.openssl = list(OPENSSL_RECENT_VERSIONS)
470        args.libressl = list(LIBRESSL_RECENT_VERSIONS)
471        if not args.disable_ancient:
472            args.openssl.extend(OPENSSL_OLD_VERSIONS)
473            args.libressl.extend(LIBRESSL_OLD_VERSIONS)
474
475    logging.basicConfig(
476        level=logging.DEBUG if args.debug else logging.INFO,
477        format="*** %(levelname)s %(message)s"
478    )
479
480    start = datetime.now()
481
482    if args.steps in {'modules', 'tests'}:
483        for name in ['Makefile.pre.in', 'Modules/_ssl.c']:
484            if not os.path.isfile(os.path.join(PYTHONROOT, name)):
485                parser.error(
486                    "Must be executed from CPython build dir"
487                )
488        if not os.path.samefile('python', sys.executable):
489            parser.error(
490                "Must be executed with ./python from CPython build dir"
491            )
492        # check for configure and run make
493        configure_make()
494
495    # download and register builder
496    builds = []
497
498    for version in args.openssl:
499        build = BuildOpenSSL(
500            version,
501            args
502        )
503        build.install()
504        builds.append(build)
505
506    for version in args.libressl:
507        build = BuildLibreSSL(
508            version,
509            args
510        )
511        build.install()
512        builds.append(build)
513
514    if args.steps in {'modules', 'tests'}:
515        for build in builds:
516            try:
517                build.recompile_pymods()
518                build.check_pyssl()
519                if args.steps == 'tests':
520                    build.run_python_tests(
521                        tests=args.tests,
522                        network=args.network,
523                    )
524            except Exception as e:
525                log.exception("%s failed", build)
526                print("{} failed: {}".format(build, e), file=sys.stderr)
527                sys.exit(2)
528
529    log.info("\n{} finished in {}".format(
530            args.steps.capitalize(),
531            datetime.now() - start
532        ))
533    print('Python: ', sys.version)
534    if args.steps == 'tests':
535        if args.tests:
536            print('Executed Tests:', ' '.join(args.tests))
537        else:
538            print('Executed all SSL tests.')
539
540    print('OpenSSL / LibreSSL versions:')
541    for build in builds:
542        print("    * {0.library} {0.version}".format(build))
543
544
545if __name__ == "__main__":
546    main()
547