You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

529 lines
16 KiB

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