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.1w",
51 "3.0.11",
52 "3.1.3",
53 ]
54
55 LIBRESSL_OLD_VERSIONS = [
56 ]
57
58 LIBRESSL_RECENT_VERSIONS = [
59 ]
60
61 # store files in ../multissl
62 HERE = os.path.dirname(os.path.abspath(__file__))
63 PYTHONROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
64 MULTISSL_DIR = os.path.abspath(os.path.join(PYTHONROOT, '..', 'multissl'))
65
66
67 parser = argparse.ArgumentParser(
68 prog='multissl',
69 description=(
70 "Run CPython tests with multiple OpenSSL and LibreSSL "
71 "versions."
72 )
73 )
74 parser.add_argument(
75 '--debug',
76 action='store_true',
77 help="Enable debug logging",
78 )
79 parser.add_argument(
80 '--disable-ancient',
81 action='store_true',
82 help="Don't test OpenSSL and LibreSSL versions without upstream support",
83 )
84 parser.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 )
93 parser.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 )
102 parser.add_argument(
103 '--tests',
104 nargs='*',
105 default=(),
106 help="Python tests to run, defaults to all SSL related tests.",
107 )
108 parser.add_argument(
109 '--base-directory',
110 default=MULTISSL_DIR,
111 help="Base directory for OpenSSL / LibreSSL sources and builds."
112 )
113 parser.add_argument(
114 '--no-network',
115 action='store_false',
116 dest='network',
117 help="Disable network tests."
118 )
119 parser.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 )
129 parser.add_argument(
130 '--system',
131 default='',
132 help="Override the automatic system type detection."
133 )
134 parser.add_argument(
135 '--force',
136 action='store_true',
137 dest='force',
138 help="Force build and installation."
139 )
140 parser.add_argument(
141 '--keep-sources',
142 action='store_true',
143 dest='keep_sources',
144 help="Keep original sources for debugging."
145 )
146
147
148 class ESC[4;38;5;81mAbstractBuilder(ESC[4;38;5;149mobject):
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
394 class ESC[4;38;5;81mBuildOpenSSL(ESC[4;38;5;149mAbstractBuilder):
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
439 class ESC[4;38;5;81mBuildLibreSSL(ESC[4;38;5;149mAbstractBuilder):
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
448 def 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
460 def 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
539 if __name__ == "__main__":
540 main()