xref: /curl/tests/http/testenv/env.py (revision 25025419)
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3#***************************************************************************
4#                                  _   _ ____  _
5#  Project                     ___| | | |  _ \| |
6#                             / __| | | | |_) | |
7#                            | (__| |_| |  _ <| |___
8#                             \___|\___/|_| \_\_____|
9#
10# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
11#
12# This software is licensed as described in the file COPYING, which
13# you should have received as part of this distribution. The terms
14# are also available at https://curl.se/docs/copyright.html.
15#
16# You may opt to use, copy, modify, merge, publish, distribute and/or sell
17# copies of the Software, and permit persons to whom the Software is
18# furnished to do so, under the terms of the COPYING file.
19#
20# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
21# KIND, either express or implied.
22#
23# SPDX-License-Identifier: curl
24#
25###########################################################################
26#
27import logging
28import os
29import re
30import shutil
31import socket
32import subprocess
33import tempfile
34from configparser import ConfigParser, ExtendedInterpolation
35from typing import Optional
36
37from .certs import CertificateSpec, Credentials, TestCA
38from .ports import alloc_ports
39
40
41log = logging.getLogger(__name__)
42
43
44def init_config_from(conf_path):
45    if os.path.isfile(conf_path):
46        config = ConfigParser(interpolation=ExtendedInterpolation())
47        config.read(conf_path)
48        return config
49    return None
50
51
52TESTS_HTTPD_PATH = os.path.dirname(os.path.dirname(__file__))
53TOP_PATH = os.path.join(os.getcwd(), os.path.pardir)
54DEF_CONFIG = init_config_from(os.path.join(TOP_PATH, 'tests', 'http', 'config.ini'))
55CURL = os.path.join(TOP_PATH, 'src', 'curl')
56
57
58class EnvConfig:
59
60    def __init__(self):
61        self.tests_dir = TESTS_HTTPD_PATH
62        self.gen_dir = os.path.join(self.tests_dir, 'gen')
63        self.project_dir = os.path.dirname(os.path.dirname(self.tests_dir))
64        self.build_dir = TOP_PATH
65        self.config = DEF_CONFIG
66        # check cur and its features
67        self.curl = CURL
68        if 'CURL' in os.environ:
69            self.curl = os.environ['CURL']
70        self.curl_props = {
71            'version_string': '',
72            'version': '',
73            'os': '',
74            'fullname': '',
75            'features_string': '',
76            'features': set(),
77            'protocols_string': '',
78            'protocols': set(),
79            'libs': set(),
80            'lib_versions': set(),
81        }
82        self.curl_is_debug = False
83        self.curl_protos = []
84        p = subprocess.run(args=[self.curl, '-V'],
85                           capture_output=True, text=True)
86        if p.returncode != 0:
87            raise RuntimeError(f'{self.curl} -V failed with exit code: {p.returncode}')
88        if p.stderr.startswith('WARNING:'):
89            self.curl_is_debug = True
90        for line in p.stdout.splitlines(keepends=False):
91            if line.startswith('curl '):
92                self.curl_props['version_string'] = line
93                m = re.match(r'^curl (?P<version>\S+) (?P<os>\S+) (?P<libs>.*)$', line)
94                if m:
95                    self.curl_props['fullname'] = m.group(0)
96                    self.curl_props['version'] = m.group('version')
97                    self.curl_props['os'] = m.group('os')
98                    self.curl_props['lib_versions'] = {
99                        lib.lower() for lib in m.group('libs').split(' ')
100                    }
101                    self.curl_props['libs'] = {
102                        re.sub(r'/[a-z0-9.-]*', '', lib) for lib in self.curl_props['lib_versions']
103                    }
104            if line.startswith('Features: '):
105                self.curl_props['features_string'] = line[10:]
106                self.curl_props['features'] = {
107                    feat.lower() for feat in line[10:].split(' ')
108                }
109            if line.startswith('Protocols: '):
110                self.curl_props['protocols_string'] = line[11:]
111                self.curl_props['protocols'] = {
112                    prot.lower() for prot in line[11:].split(' ')
113                }
114
115        self.ports = alloc_ports(port_specs={
116            'ftp': socket.SOCK_STREAM,
117            'ftps': socket.SOCK_STREAM,
118            'http': socket.SOCK_STREAM,
119            'https': socket.SOCK_STREAM,
120            'nghttpx_https': socket.SOCK_STREAM,
121            'proxy': socket.SOCK_STREAM,
122            'proxys': socket.SOCK_STREAM,
123            'h2proxys': socket.SOCK_STREAM,
124            'caddy': socket.SOCK_STREAM,
125            'caddys': socket.SOCK_STREAM,
126            'ws': socket.SOCK_STREAM,
127        })
128        self.httpd = self.config['httpd']['httpd']
129        self.apachectl = self.config['httpd']['apachectl']
130        self.apxs = self.config['httpd']['apxs']
131        if len(self.apxs) == 0:
132            self.apxs = None
133        self._httpd_version = None
134
135        self.examples_pem = {
136            'key': 'xxx',
137            'cert': 'xxx',
138        }
139        self.htdocs_dir = os.path.join(self.gen_dir, 'htdocs')
140        self.tld = 'http.curl.se'
141        self.domain1 = f"one.{self.tld}"
142        self.domain1brotli = f"brotli.one.{self.tld}"
143        self.domain2 = f"two.{self.tld}"
144        self.ftp_domain = f"ftp.{self.tld}"
145        self.proxy_domain = f"proxy.{self.tld}"
146        self.cert_specs = [
147            CertificateSpec(domains=[self.domain1, self.domain1brotli, 'localhost', '127.0.0.1'], key_type='rsa2048'),
148            CertificateSpec(domains=[self.domain2], key_type='rsa2048'),
149            CertificateSpec(domains=[self.ftp_domain], key_type='rsa2048'),
150            CertificateSpec(domains=[self.proxy_domain, '127.0.0.1'], key_type='rsa2048'),
151            CertificateSpec(name="clientsX", sub_specs=[
152               CertificateSpec(name="user1", client=True),
153            ]),
154        ]
155
156        self.nghttpx = self.config['nghttpx']['nghttpx']
157        if len(self.nghttpx.strip()) == 0:
158            self.nghttpx = None
159        self._nghttpx_version = None
160        self.nghttpx_with_h3 = False
161        if self.nghttpx is not None:
162            p = subprocess.run(args=[self.nghttpx, '-v'],
163                               capture_output=True, text=True)
164            if p.returncode != 0:
165                # not a working nghttpx
166                self.nghttpx = None
167            else:
168                self._nghttpx_version = re.sub(r'^nghttpx\s*', '', p.stdout.strip())
169                self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip()) is not None
170                log.debug(f'nghttpx -v: {p.stdout}')
171
172        self.caddy = self.config['caddy']['caddy']
173        self._caddy_version = None
174        if len(self.caddy.strip()) == 0:
175            self.caddy = None
176        if self.caddy is not None:
177            try:
178                p = subprocess.run(args=[self.caddy, 'version'],
179                                   capture_output=True, text=True)
180                if p.returncode != 0:
181                    # not a working caddy
182                    self.caddy = None
183                m = re.match(r'v?(\d+\.\d+\.\d+).*', p.stdout)
184                if m:
185                    self._caddy_version = m.group(1)
186                else:
187                    raise RuntimeError(f'Unable to determine cadd version from: {p.stdout}')
188            # TODO: specify specific exceptions here
189            except:  # noqa: E722
190                self.caddy = None
191
192        self.vsftpd = self.config['vsftpd']['vsftpd']
193        self._vsftpd_version = None
194        if self.vsftpd is not None:
195            try:
196                with tempfile.TemporaryFile('w+') as tmp:
197                    p = subprocess.run(args=[self.vsftpd, '-v'],
198                                       capture_output=True, text=True, stdin=tmp)
199                    if p.returncode != 0:
200                        # not a working vsftpd
201                        self.vsftpd = None
202                    if p.stderr:
203                        ver_text = p.stderr
204                    else:
205                        # Oddly, some versions of vsftpd write to stdin (!)
206                        # instead of stderr, which is odd but works. If there
207                        # is nothing on stderr, read the file on stdin and use
208                        # any data there instead.
209                        tmp.seek(0)
210                        ver_text = tmp.read()
211                m = re.match(r'vsftpd: version (\d+\.\d+\.\d+)', ver_text)
212                if m:
213                    self._vsftpd_version = m.group(1)
214                elif len(p.stderr) == 0:
215                    # vsftp does not use stdout or stderr for printing its version... -.-
216                    self._vsftpd_version = 'unknown'
217                else:
218                    raise Exception(f'Unable to determine VsFTPD version from: {p.stderr}')
219            except Exception:
220                self.vsftpd = None
221
222        self._tcpdump = shutil.which('tcpdump')
223
224    @property
225    def httpd_version(self):
226        if self._httpd_version is None and self.apxs is not None:
227            try:
228                p = subprocess.run(args=[self.apxs, '-q', 'HTTPD_VERSION'],
229                                   capture_output=True, text=True)
230                if p.returncode != 0:
231                    log.error(f'{self.apxs} failed to query HTTPD_VERSION: {p}')
232                else:
233                    self._httpd_version = p.stdout.strip()
234            except Exception:
235                log.exception(f'{self.apxs} failed to run')
236        return self._httpd_version
237
238    def versiontuple(self, v):
239        v = re.sub(r'(\d+\.\d+(\.\d+)?)(-\S+)?', r'\1', v)
240        return tuple(map(int, v.split('.')))
241
242    def httpd_is_at_least(self, minv):
243        if self.httpd_version is None:
244            return False
245        hv = self.versiontuple(self.httpd_version)
246        return hv >= self.versiontuple(minv)
247
248    def caddy_is_at_least(self, minv):
249        if self.caddy_version is None:
250            return False
251        hv = self.versiontuple(self.caddy_version)
252        return hv >= self.versiontuple(minv)
253
254    def is_complete(self) -> bool:
255        return os.path.isfile(self.httpd) and \
256               os.path.isfile(self.apachectl) and \
257               self.apxs is not None and \
258               os.path.isfile(self.apxs)
259
260    def get_incomplete_reason(self) -> Optional[str]:
261        if self.httpd is None or len(self.httpd.strip()) == 0:
262            return 'httpd not configured, see `--with-test-httpd=<path>`'
263        if not os.path.isfile(self.httpd):
264            return f'httpd ({self.httpd}) not found'
265        if not os.path.isfile(self.apachectl):
266            return f'apachectl ({self.apachectl}) not found'
267        if self.apxs is None:
268            return "command apxs not found (commonly provided in apache2-dev)"
269        if not os.path.isfile(self.apxs):
270            return f"apxs ({self.apxs}) not found"
271        return None
272
273    @property
274    def nghttpx_version(self):
275        return self._nghttpx_version
276
277    @property
278    def caddy_version(self):
279        return self._caddy_version
280
281    @property
282    def vsftpd_version(self):
283        return self._vsftpd_version
284
285    @property
286    def tcpdmp(self) -> Optional[str]:
287        return self._tcpdump
288
289
290class Env:
291
292    CONFIG = EnvConfig()
293
294    @staticmethod
295    def setup_incomplete() -> bool:
296        return not Env.CONFIG.is_complete()
297
298    @staticmethod
299    def incomplete_reason() -> Optional[str]:
300        return Env.CONFIG.get_incomplete_reason()
301
302    @staticmethod
303    def have_nghttpx() -> bool:
304        return Env.CONFIG.nghttpx is not None
305
306    @staticmethod
307    def have_h3_server() -> bool:
308        return Env.CONFIG.nghttpx_with_h3
309
310    @staticmethod
311    def have_ssl_curl() -> bool:
312        return Env.curl_has_feature('ssl') or Env.curl_has_feature('multissl')
313
314    @staticmethod
315    def have_h2_curl() -> bool:
316        return 'http2' in Env.CONFIG.curl_props['features']
317
318    @staticmethod
319    def have_h3_curl() -> bool:
320        return 'http3' in Env.CONFIG.curl_props['features']
321
322    @staticmethod
323    def curl_uses_lib(libname: str) -> bool:
324        return libname.lower() in Env.CONFIG.curl_props['libs']
325
326    @staticmethod
327    def curl_uses_ossl_quic() -> bool:
328        if Env.have_h3_curl():
329            return not Env.curl_uses_lib('ngtcp2') and Env.curl_uses_lib('nghttp3')
330        return False
331
332    @staticmethod
333    def curl_version_string() -> str:
334        return Env.CONFIG.curl_props['version_string']
335
336    @staticmethod
337    def curl_features_string() -> str:
338        return Env.CONFIG.curl_props['features_string']
339
340    @staticmethod
341    def curl_has_feature(feature: str) -> bool:
342        return feature.lower() in Env.CONFIG.curl_props['features']
343
344    @staticmethod
345    def curl_protocols_string() -> str:
346        return Env.CONFIG.curl_props['protocols_string']
347
348    @staticmethod
349    def curl_has_protocol(protocol: str) -> bool:
350        return protocol.lower() in Env.CONFIG.curl_props['protocols']
351
352    @staticmethod
353    def curl_lib_version(libname: str) -> str:
354        prefix = f'{libname.lower()}/'
355        for lversion in Env.CONFIG.curl_props['lib_versions']:
356            if lversion.startswith(prefix):
357                return lversion[len(prefix):]
358        return 'unknown'
359
360    @staticmethod
361    def curl_lib_version_at_least(libname: str, min_version) -> bool:
362        lversion = Env.curl_lib_version(libname)
363        if lversion != 'unknown':
364            return Env.CONFIG.versiontuple(min_version) <= \
365                   Env.CONFIG.versiontuple(lversion)
366        return False
367
368    @staticmethod
369    def curl_os() -> str:
370        return Env.CONFIG.curl_props['os']
371
372    @staticmethod
373    def curl_fullname() -> str:
374        return Env.CONFIG.curl_props['fullname']
375
376    @staticmethod
377    def curl_version() -> str:
378        return Env.CONFIG.curl_props['version']
379
380    @staticmethod
381    def curl_is_debug() -> bool:
382        return Env.CONFIG.curl_is_debug
383
384    @staticmethod
385    def have_h3() -> bool:
386        return Env.have_h3_curl() and Env.have_h3_server()
387
388    @staticmethod
389    def httpd_version() -> str:
390        return Env.CONFIG.httpd_version
391
392    @staticmethod
393    def nghttpx_version() -> str:
394        return Env.CONFIG.nghttpx_version
395
396    @staticmethod
397    def caddy_version() -> str:
398        return Env.CONFIG.caddy_version
399
400    @staticmethod
401    def caddy_is_at_least(minv) -> bool:
402        return Env.CONFIG.caddy_is_at_least(minv)
403
404    @staticmethod
405    def httpd_is_at_least(minv) -> bool:
406        return Env.CONFIG.httpd_is_at_least(minv)
407
408    @staticmethod
409    def has_caddy() -> bool:
410        return Env.CONFIG.caddy is not None
411
412    @staticmethod
413    def has_vsftpd() -> bool:
414        return Env.CONFIG.vsftpd is not None
415
416    @staticmethod
417    def vsftpd_version() -> str:
418        return Env.CONFIG.vsftpd_version
419
420    @staticmethod
421    def tcpdump() -> Optional[str]:
422        return Env.CONFIG.tcpdmp
423
424    def __init__(self, pytestconfig=None):
425        self._verbose = pytestconfig.option.verbose \
426            if pytestconfig is not None else 0
427        self._ca = None
428        self._test_timeout = 300.0 if self._verbose > 1 else 60.0  # seconds
429
430    def issue_certs(self):
431        if self._ca is None:
432            ca_dir = os.path.join(self.CONFIG.gen_dir, 'ca')
433            self._ca = TestCA.create_root(name=self.CONFIG.tld,
434                                          store_dir=ca_dir,
435                                          key_type="rsa2048")
436        self._ca.issue_certs(self.CONFIG.cert_specs)
437
438    def setup(self):
439        os.makedirs(self.gen_dir, exist_ok=True)
440        os.makedirs(self.htdocs_dir, exist_ok=True)
441        self.issue_certs()
442
443    def get_credentials(self, domain) -> Optional[Credentials]:
444        creds = self.ca.get_credentials_for_name(domain)
445        if len(creds) > 0:
446            return creds[0]
447        return None
448
449    @property
450    def verbose(self) -> int:
451        return self._verbose
452
453    @property
454    def test_timeout(self) -> Optional[float]:
455        return self._test_timeout
456
457    @test_timeout.setter
458    def test_timeout(self, val: Optional[float]):
459        self._test_timeout = val
460
461    @property
462    def gen_dir(self) -> str:
463        return self.CONFIG.gen_dir
464
465    @property
466    def project_dir(self) -> str:
467        return self.CONFIG.project_dir
468
469    @property
470    def build_dir(self) -> str:
471        return self.CONFIG.build_dir
472
473    @property
474    def ca(self):
475        return self._ca
476
477    @property
478    def htdocs_dir(self) -> str:
479        return self.CONFIG.htdocs_dir
480
481    @property
482    def tld(self) -> str:
483        return self.CONFIG.tld
484
485    @property
486    def domain1(self) -> str:
487        return self.CONFIG.domain1
488
489    @property
490    def domain1brotli(self) -> str:
491        return self.CONFIG.domain1brotli
492
493    @property
494    def domain2(self) -> str:
495        return self.CONFIG.domain2
496
497    @property
498    def ftp_domain(self) -> str:
499        return self.CONFIG.ftp_domain
500
501    @property
502    def proxy_domain(self) -> str:
503        return self.CONFIG.proxy_domain
504
505    @property
506    def http_port(self) -> int:
507        return self.CONFIG.ports['http']
508
509    @property
510    def https_port(self) -> int:
511        return self.CONFIG.ports['https']
512
513    @property
514    def nghttpx_https_port(self) -> int:
515        return self.CONFIG.ports['nghttpx_https']
516
517    @property
518    def h3_port(self) -> int:
519        return self.https_port
520
521    @property
522    def proxy_port(self) -> int:
523        return self.CONFIG.ports['proxy']
524
525    @property
526    def proxys_port(self) -> int:
527        return self.CONFIG.ports['proxys']
528
529    @property
530    def ftp_port(self) -> int:
531        return self.CONFIG.ports['ftp']
532
533    @property
534    def ftps_port(self) -> int:
535        return self.CONFIG.ports['ftps']
536
537    @property
538    def h2proxys_port(self) -> int:
539        return self.CONFIG.ports['h2proxys']
540
541    def pts_port(self, proto: str = 'http/1.1') -> int:
542        # proxy tunnel port
543        return self.CONFIG.ports['h2proxys' if proto == 'h2' else 'proxys']
544
545    @property
546    def caddy(self) -> str:
547        return self.CONFIG.caddy
548
549    @property
550    def caddy_https_port(self) -> int:
551        return self.CONFIG.ports['caddys']
552
553    @property
554    def caddy_http_port(self) -> int:
555        return self.CONFIG.ports['caddy']
556
557    @property
558    def vsftpd(self) -> str:
559        return self.CONFIG.vsftpd
560
561    @property
562    def ws_port(self) -> int:
563        return self.CONFIG.ports['ws']
564
565    @property
566    def curl(self) -> str:
567        return self.CONFIG.curl
568
569    @property
570    def httpd(self) -> str:
571        return self.CONFIG.httpd
572
573    @property
574    def apachectl(self) -> str:
575        return self.CONFIG.apachectl
576
577    @property
578    def apxs(self) -> str:
579        return self.CONFIG.apxs
580
581    @property
582    def nghttpx(self) -> Optional[str]:
583        return self.CONFIG.nghttpx
584
585    @property
586    def slow_network(self) -> bool:
587        return "CURL_DBG_SOCK_WBLOCK" in os.environ or \
588               "CURL_DBG_SOCK_WPARTIAL" in os.environ
589
590    @property
591    def ci_run(self) -> bool:
592        return "CURL_CI" in os.environ
593
594    def port_for(self, alpn_proto: Optional[str] = None):
595        if alpn_proto is None or \
596                alpn_proto in ['h2', 'http/1.1', 'http/1.0', 'http/0.9']:
597            return self.https_port
598        if alpn_proto in ['h3']:
599            return self.h3_port
600        return self.http_port
601
602    def authority_for(self, domain: str, alpn_proto: Optional[str] = None):
603        return f'{domain}:{self.port_for(alpn_proto=alpn_proto)}'
604
605    def make_data_file(self, indir: str, fname: str, fsize: int,
606                       line_length: int = 1024) -> str:
607        if line_length < 11:
608            raise RuntimeError('line_length less than 11 not supported')
609        fpath = os.path.join(indir, fname)
610        s10 = "0123456789"
611        s = round((line_length / 10) + 1) * s10
612        s = s[0:line_length-11]
613        with open(fpath, 'w') as fd:
614            for i in range(int(fsize / line_length)):
615                fd.write(f"{i:09d}-{s}\n")
616            remain = int(fsize % line_length)
617            if remain != 0:
618                i = int(fsize / line_length) + 1
619                fd.write(f"{i:09d}-{s}"[0:remain-1] + "\n")
620        return fpath
621