• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 #!./python
2 """Run Python tests against multiple installations of OpenSSL and LibreSSL
3 
4 The 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 
14 The script must be run with Python's build directory as current working
15 directory.
16 
17 The script uses LD_RUN_PATH, LD_LIBRARY_PATH, CPPFLAGS and LDFLAGS to bend
18 search paths for header files and shared libraries. It's known to work on
19 Linux with GCC and clang.
20 
21 Please 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 """
25 from __future__ import print_function
26 
27 import argparse
28 from datetime import datetime
29 import logging
30 import os
31 try:
32     from urllib.request import urlopen
33     from urllib.error import HTTPError
34 except ImportError:
35     from urllib2 import urlopen, HTTPError
36 import re
37 import shutil
38 import string
39 import subprocess
40 import sys
41 import tarfile
42 
43 
44 log = logging.getLogger("multissl")
45 
46 OPENSSL_OLD_VERSIONS = [
47 ]
48 
49 OPENSSL_RECENT_VERSIONS = [
50     "1.1.1m",
51     "3.0.1"
52 ]
53 
54 LIBRESSL_OLD_VERSIONS = [
55 ]
56 
57 LIBRESSL_RECENT_VERSIONS = [
58 ]
59 
60 # store files in ../multissl
61 HERE = os.path.dirname(os.path.abspath(__file__))
62 PYTHONROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
63 MULTISSL_DIR = os.path.abspath(os.path.join(PYTHONROOT, '..', 'multissl'))
64 
65 
66 parser = argparse.ArgumentParser(
67     prog='multissl',
68     description=(
69         "Run CPython tests with multiple OpenSSL and LibreSSL "
70         "versions."
71     )
72 )
73 parser.add_argument(
74     '--debug',
75     action='store_true',
76     help="Enable debug logging",
77 )
78 parser.add_argument(
79     '--disable-ancient',
80     action='store_true',
81     help="Don't test OpenSSL and LibreSSL versions without upstream support",
82 )
83 parser.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 )
92 parser.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 )
101 parser.add_argument(
102     '--tests',
103     nargs='*',
104     default=(),
105     help="Python tests to run, defaults to all SSL related tests.",
106 )
107 parser.add_argument(
108     '--base-directory',
109     default=MULTISSL_DIR,
110     help="Base directory for OpenSSL / LibreSSL sources and builds."
111 )
112 parser.add_argument(
113     '--no-network',
114     action='store_false',
115     dest='network',
116     help="Disable network tests."
117 )
118 parser.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 )
128 parser.add_argument(
129     '--system',
130     default='',
131     help="Override the automatic system type detection."
132 )
133 parser.add_argument(
134     '--force',
135     action='store_true',
136     dest='force',
137     help="Force build and installation."
138 )
139 parser.add_argument(
140     '--keep-sources',
141     action='store_true',
142     dest='keep_sources',
143     help="Keep original sources for debugging."
144 )
145 
146 
147 class 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 
393 class 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 
438 class 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 
447 def 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 
459 def 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 
538 if __name__ == "__main__":
539     main()
540