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