xref: /curl/tests/http/testenv/curl.py (revision 5a913d8d)
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 json
28import logging
29import os
30import psutil
31import re
32import shutil
33import subprocess
34from statistics import mean, fmean
35from datetime import timedelta, datetime
36from typing import List, Optional, Dict, Union, Any
37from urllib.parse import urlparse
38
39from .env import Env
40
41
42log = logging.getLogger(__name__)
43
44
45class RunProfile:
46
47    STAT_KEYS = ['cpu', 'rss', 'vsz']
48
49    @classmethod
50    def AverageStats(cls, profiles: List['RunProfile']):
51        avg = {}
52        stats = [p.stats for p in profiles]
53        for key in cls.STAT_KEYS:
54            avg[key] = mean([s[key] for s in stats])
55        return avg
56
57    def __init__(self, pid: int, started_at: datetime, run_dir):
58        self._pid = pid
59        self._started_at = started_at
60        self._duration = timedelta(seconds=0)
61        self._run_dir = run_dir
62        self._samples = []
63        self._psu = None
64        self._stats = None
65
66    @property
67    def duration(self) -> timedelta:
68        return self._duration
69
70    @property
71    def stats(self) -> Optional[Dict[str,Any]]:
72        return self._stats
73
74    def sample(self):
75        elapsed = datetime.now() - self._started_at
76        try:
77            if self._psu is None:
78                self._psu = psutil.Process(pid=self._pid)
79            mem = self._psu.memory_info()
80            self._samples.append({
81                'time': elapsed,
82                'cpu': self._psu.cpu_percent(),
83                'vsz': mem.vms,
84                'rss': mem.rss,
85            })
86        except psutil.NoSuchProcess:
87            pass
88
89    def finish(self):
90        self._duration = datetime.now() - self._started_at
91        if len(self._samples) > 0:
92            weights = [s['time'].total_seconds() for s in self._samples]
93            self._stats = {}
94            for key in self.STAT_KEYS:
95                self._stats[key] = fmean([s[key] for s in self._samples], weights)
96        else:
97            self._stats = None
98        self._psu = None
99
100    def __repr__(self):
101        return f'RunProfile[pid={self._pid}, '\
102               f'duration={self.duration.total_seconds():.3f}s, '\
103               f'stats={self.stats}]'
104
105
106class ExecResult:
107
108    def __init__(self, args: List[str], exit_code: int,
109                 stdout: List[str], stderr: List[str],
110                 duration: Optional[timedelta] = None,
111                 with_stats: bool = False,
112                 exception: Optional[str] = None,
113                 profile: Optional[RunProfile] = None):
114        self._args = args
115        self._exit_code = exit_code
116        self._exception = exception
117        self._stdout = stdout
118        self._stderr = stderr
119        self._profile = profile
120        self._duration = duration if duration is not None else timedelta()
121        self._response = None
122        self._responses = []
123        self._results = {}
124        self._assets = []
125        self._stats = []
126        self._json_out = None
127        self._with_stats = with_stats
128        if with_stats:
129            self._parse_stats()
130        else:
131            # noinspection PyBroadException
132            try:
133                out = ''.join(self._stdout)
134                self._json_out = json.loads(out)
135            except:
136                pass
137
138    def __repr__(self):
139        return f"ExecResult[code={self.exit_code}, exception={self._exception}, "\
140               f"args={self._args}, stdout={self._stdout}, stderr={self._stderr}]"
141
142    def _parse_stats(self):
143        self._stats = []
144        for l in self._stdout:
145            try:
146                self._stats.append(json.loads(l))
147            except:
148                log.error(f'not a JSON stat: {l}')
149                break
150
151    @property
152    def exit_code(self) -> int:
153        return self._exit_code
154
155    @property
156    def args(self) -> List[str]:
157        return self._args
158
159    @property
160    def outraw(self) -> bytes:
161        return ''.join(self._stdout).encode()
162
163    @property
164    def stdout(self) -> str:
165        return ''.join(self._stdout)
166
167    @property
168    def json(self) -> Optional[Dict]:
169        """Output as JSON dictionary or None if not parseable."""
170        return self._json_out
171
172    @property
173    def stderr(self) -> str:
174        return ''.join(self._stderr)
175
176    @property
177    def trace_lines(self) -> List[str]:
178        return self._stderr
179
180    @property
181    def duration(self) -> timedelta:
182        return self._duration
183
184    @property
185    def profile(self) -> Optional[RunProfile]:
186        return self._profile
187
188    @property
189    def response(self) -> Optional[Dict]:
190        return self._response
191
192    @property
193    def responses(self) -> List[Dict]:
194        return self._responses
195
196    @property
197    def results(self) -> Dict:
198        return self._results
199
200    @property
201    def assets(self) -> List:
202        return self._assets
203
204    @property
205    def with_stats(self) -> bool:
206        return self._with_stats
207
208    @property
209    def stats(self) -> List:
210        return self._stats
211
212    @property
213    def total_connects(self) -> Optional[int]:
214        if len(self.stats):
215            n = 0
216            for stat in self.stats:
217                n += stat['num_connects']
218            return n
219        return None
220
221    def add_response(self, resp: Dict):
222        self._response = resp
223        self._responses.append(resp)
224
225    def add_results(self, results: Dict):
226        self._results.update(results)
227        if 'response' in results:
228            self.add_response(results['response'])
229
230    def add_assets(self, assets: List):
231        self._assets.extend(assets)
232
233    def check_exit_code(self, code: Union[int, bool]):
234        if code is True:
235            assert self.exit_code == 0, f'expected exit code {code}, '\
236                                        f'got {self.exit_code}\n{self.dump_logs()}'
237        elif code is False:
238            assert self.exit_code != 0, f'expected exit code {code}, '\
239                                                f'got {self.exit_code}\n{self.dump_logs()}'
240        else:
241            assert self.exit_code == code, f'expected exit code {code}, '\
242                                           f'got {self.exit_code}\n{self.dump_logs()}'
243
244    def check_response(self, http_status: Optional[int] = 200,
245                       count: Optional[int] = 1,
246                       protocol: Optional[str] = None,
247                       exitcode: Optional[int] = 0,
248                       connect_count: Optional[int] = None):
249        if exitcode:
250            self.check_exit_code(exitcode)
251            if self.with_stats and isinstance(exitcode, int):
252                for idx, x in enumerate(self.stats):
253                    if 'exitcode' in x:
254                        assert int(x['exitcode']) == exitcode, \
255                            f'response #{idx} exitcode: expected {exitcode}, '\
256                            f'got {x["exitcode"]}\n{self.dump_logs()}'
257
258        if self.with_stats:
259            assert len(self.stats) == count, \
260                f'response count: expected {count}, ' \
261                f'got {len(self.stats)}\n{self.dump_logs()}'
262        else:
263            assert len(self.responses) == count, \
264                f'response count: expected {count}, ' \
265                f'got {len(self.responses)}\n{self.dump_logs()}'
266        if http_status is not None:
267            if self.with_stats:
268                for idx, x in enumerate(self.stats):
269                    assert 'http_code' in x, \
270                        f'response #{idx} reports no http_code\n{self.dump_stat(x)}'
271                    assert x['http_code'] == http_status, \
272                        f'response #{idx} http_code: expected {http_status}, '\
273                        f'got {x["http_code"]}\n{self.dump_stat(x)}'
274            else:
275                for idx, x in enumerate(self.responses):
276                    assert x['status'] == http_status, \
277                        f'response #{idx} status: expected {http_status},'\
278                        f'got {x["status"]}\n{self.dump_stat(x)}'
279        if protocol is not None:
280            if self.with_stats:
281                http_version = None
282                if protocol == 'HTTP/1.1':
283                    http_version = '1.1'
284                elif protocol == 'HTTP/2':
285                    http_version = '2'
286                elif protocol == 'HTTP/3':
287                    http_version = '3'
288                if http_version is not None:
289                    for idx, x in enumerate(self.stats):
290                        assert x['http_version'] == http_version, \
291                            f'response #{idx} protocol: expected http/{http_version},' \
292                            f'got version {x["http_version"]}\n{self.dump_stat(x)}'
293            else:
294                for idx, x in enumerate(self.responses):
295                    assert x['protocol'] == protocol, \
296                        f'response #{idx} protocol: expected {protocol},'\
297                        f'got {x["protocol"]}\n{self.dump_logs()}'
298        if connect_count is not None:
299            assert self.total_connects == connect_count, \
300                f'expected {connect_count}, but {self.total_connects} '\
301                f'were made\n{self.dump_logs()}'
302
303    def check_stats(self, count: int, http_status: Optional[int] = None,
304                    exitcode: Optional[int] = None):
305        if exitcode is None:
306            self.check_exit_code(0)
307        assert len(self.stats) == count, \
308            f'stats count: expected {count}, got {len(self.stats)}\n{self.dump_logs()}'
309        if http_status is not None:
310            for idx, x in enumerate(self.stats):
311                assert 'http_code' in x, \
312                    f'status #{idx} reports no http_code\n{self.dump_stat(x)}'
313                assert x['http_code'] == http_status, \
314                    f'status #{idx} http_code: expected {http_status}, '\
315                    f'got {x["http_code"]}\n{self.dump_stat(x)}'
316        if exitcode is not None:
317            for idx, x in enumerate(self.stats):
318                if 'exitcode' in x:
319                    assert x['exitcode'] == exitcode, \
320                        f'status #{idx} exitcode: expected {exitcode}, '\
321                        f'got {x["exitcode"]}\n{self.dump_stat(x)}'
322
323    def dump_logs(self):
324        lines = ['>>--stdout ----------------------------------------------\n']
325        lines.extend(self._stdout)
326        lines.append('>>--stderr ----------------------------------------------\n')
327        lines.extend(self._stderr)
328        lines.append('<<-------------------------------------------------------\n')
329        return ''.join(lines)
330
331    def dump_stat(self, x):
332        lines = [
333            'json stat from curl:',
334            json.JSONEncoder(indent=2).encode(x),
335        ]
336        if 'xfer_id' in x:
337            xfer_id = x['xfer_id']
338            lines.append(f'>>--xfer {xfer_id} trace:\n')
339            lines.extend(self.xfer_trace_for(xfer_id))
340        else:
341            lines.append('>>--full trace-------------------------------------------\n')
342            lines.extend(self._stderr)
343            lines.append('<<-------------------------------------------------------\n')
344        return ''.join(lines)
345
346    def xfer_trace_for(self, xfer_id) -> List[str]:
347            pat = re.compile(f'^[^[]* \\[{xfer_id}-.*$')
348            return [line for line in self._stderr if pat.match(line)]
349
350
351class CurlClient:
352
353    ALPN_ARG = {
354        'http/0.9': '--http0.9',
355        'http/1.0': '--http1.0',
356        'http/1.1': '--http1.1',
357        'h2': '--http2',
358        'h2c': '--http2',
359        'h3': '--http3-only',
360    }
361
362    def __init__(self, env: Env, run_dir: Optional[str] = None,
363                 timeout: Optional[float] = None, silent: bool = False):
364        self.env = env
365        self._timeout = timeout if timeout else env.test_timeout
366        self._curl = os.environ['CURL'] if 'CURL' in os.environ else env.curl
367        self._run_dir = run_dir if run_dir else os.path.join(env.gen_dir, 'curl')
368        self._stdoutfile = f'{self._run_dir}/curl.stdout'
369        self._stderrfile = f'{self._run_dir}/curl.stderr'
370        self._headerfile = f'{self._run_dir}/curl.headers'
371        self._log_path = f'{self._run_dir}/curl.log'
372        self._silent = silent
373        self._rmrf(self._run_dir)
374        self._mkpath(self._run_dir)
375
376    @property
377    def run_dir(self) -> str:
378        return self._run_dir
379
380    def download_file(self, i: int) -> str:
381        return os.path.join(self.run_dir, f'download_{i}.data')
382
383    def _rmf(self, path):
384        if os.path.exists(path):
385            return os.remove(path)
386
387    def _rmrf(self, path):
388        if os.path.exists(path):
389            return shutil.rmtree(path)
390
391    def _mkpath(self, path):
392        if not os.path.exists(path):
393            return os.makedirs(path)
394
395    def get_proxy_args(self, proto: str = 'http/1.1',
396                       proxys: bool = True, tunnel: bool = False,
397                       use_ip: bool = False):
398        proxy_name = '127.0.0.1' if use_ip else self.env.proxy_domain
399        if proxys:
400            pport = self.env.pts_port(proto) if tunnel else self.env.proxys_port
401            xargs = [
402                '--proxy', f'https://{proxy_name}:{pport}/',
403                '--resolve', f'{proxy_name}:{pport}:127.0.0.1',
404                '--proxy-cacert', self.env.ca.cert_file,
405            ]
406            if proto == 'h2':
407                xargs.append('--proxy-http2')
408        else:
409            xargs = [
410                '--proxy', f'http://{proxy_name}:{self.env.proxy_port}/',
411                '--resolve', f'{proxy_name}:{self.env.proxy_port}:127.0.0.1',
412            ]
413        if tunnel:
414            xargs.append('--proxytunnel')
415        return xargs
416
417    def http_get(self, url: str, extra_args: Optional[List[str]] = None,
418                 alpn_proto: Optional[str] = None,
419                 def_tracing: bool = True,
420                 with_stats: bool = False,
421                 with_profile: bool = False):
422        return self._raw(url, options=extra_args,
423                         with_stats=with_stats,
424                         alpn_proto=alpn_proto,
425                         def_tracing=def_tracing,
426                         with_profile=with_profile)
427
428    def http_download(self, urls: List[str],
429                      alpn_proto: Optional[str] = None,
430                      with_stats: bool = True,
431                      with_headers: bool = False,
432                      with_profile: bool = False,
433                      no_save: bool = False,
434                      extra_args: List[str] = None):
435        if extra_args is None:
436            extra_args = []
437        if no_save:
438            extra_args.extend([
439                '-o', '/dev/null',
440            ])
441        else:
442            extra_args.extend([
443                '-o', 'download_#1.data',
444            ])
445        # remove any existing ones
446        for i in range(100):
447            self._rmf(self.download_file(i))
448        if with_stats:
449            extra_args.extend([
450                '-w', '%{json}\\n'
451            ])
452        return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
453                         with_stats=with_stats,
454                         with_headers=with_headers,
455                         with_profile=with_profile)
456
457    def http_upload(self, urls: List[str], data: str,
458                    alpn_proto: Optional[str] = None,
459                    with_stats: bool = True,
460                    with_headers: bool = False,
461                    with_profile: bool = False,
462                    extra_args: Optional[List[str]] = None):
463        if extra_args is None:
464            extra_args = []
465        extra_args.extend([
466            '--data-binary', data, '-o', 'download_#1.data',
467        ])
468        if with_stats:
469            extra_args.extend([
470                '-w', '%{json}\\n'
471            ])
472        return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
473                         with_stats=with_stats,
474                         with_headers=with_headers,
475                         with_profile=with_profile)
476
477    def http_delete(self, urls: List[str],
478                    alpn_proto: Optional[str] = None,
479                    with_stats: bool = True,
480                    with_profile: bool = False,
481                    extra_args: Optional[List[str]] = None):
482        if extra_args is None:
483            extra_args = []
484        extra_args.extend([
485            '-X', 'DELETE', '-o', '/dev/null',
486        ])
487        if with_stats:
488            extra_args.extend([
489                '-w', '%{json}\\n'
490            ])
491        return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
492                         with_stats=with_stats,
493                         with_headers=False,
494                         with_profile=with_profile)
495
496    def http_put(self, urls: List[str], data=None, fdata=None,
497                 alpn_proto: Optional[str] = None,
498                 with_stats: bool = True,
499                 with_headers: bool = False,
500                 with_profile: bool = False,
501                 extra_args: Optional[List[str]] = None):
502        if extra_args is None:
503            extra_args = []
504        if fdata is not None:
505            extra_args.extend(['-T', fdata])
506        elif data is not None:
507            extra_args.extend(['-T', '-'])
508        extra_args.extend([
509            '-o', 'download_#1.data',
510        ])
511        if with_stats:
512            extra_args.extend([
513                '-w', '%{json}\\n'
514            ])
515        return self._raw(urls, intext=data,
516                         alpn_proto=alpn_proto, options=extra_args,
517                         with_stats=with_stats,
518                         with_headers=with_headers,
519                         with_profile=with_profile)
520
521    def http_form(self, urls: List[str], form: Dict[str, str],
522                  alpn_proto: Optional[str] = None,
523                  with_stats: bool = True,
524                  with_headers: bool = False,
525                  extra_args: Optional[List[str]] = None):
526        if extra_args is None:
527            extra_args = []
528        for key, val in form.items():
529            extra_args.extend(['-F', f'{key}={val}'])
530        extra_args.extend([
531            '-o', 'download_#1.data',
532        ])
533        if with_stats:
534            extra_args.extend([
535                '-w', '%{json}\\n'
536            ])
537        return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
538                         with_stats=with_stats,
539                         with_headers=with_headers)
540
541    def ftp_get(self, urls: List[str],
542                      with_stats: bool = True,
543                      with_profile: bool = False,
544                      no_save: bool = False,
545                      extra_args: List[str] = None):
546        if extra_args is None:
547            extra_args = []
548        if no_save:
549            extra_args.extend([
550                '-o', '/dev/null',
551            ])
552        else:
553            extra_args.extend([
554                '-o', 'download_#1.data',
555            ])
556        # remove any existing ones
557        for i in range(100):
558            self._rmf(self.download_file(i))
559        if with_stats:
560            extra_args.extend([
561                '-w', '%{json}\\n'
562            ])
563        return self._raw(urls, options=extra_args,
564                         with_stats=with_stats,
565                         with_headers=False,
566                         with_profile=with_profile)
567
568    def ftp_ssl_get(self, urls: List[str],
569                      with_stats: bool = True,
570                      with_profile: bool = False,
571                      no_save: bool = False,
572                      extra_args: List[str] = None):
573        if extra_args is None:
574            extra_args = []
575        extra_args.extend([
576            '--ssl-reqd',
577        ])
578        return self.ftp_get(urls=urls, with_stats=with_stats,
579                            with_profile=with_profile, no_save=no_save,
580                            extra_args=extra_args)
581
582    def ftp_upload(self, urls: List[str], fupload,
583                   with_stats: bool = True,
584                   with_profile: bool = False,
585                   extra_args: List[str] = None):
586        if extra_args is None:
587            extra_args = []
588        extra_args.extend([
589            '--upload-file', fupload
590        ])
591        if with_stats:
592            extra_args.extend([
593                '-w', '%{json}\\n'
594            ])
595        return self._raw(urls, options=extra_args,
596                         with_stats=with_stats,
597                         with_headers=False,
598                         with_profile=with_profile)
599
600    def ftp_ssl_upload(self, urls: List[str], fupload,
601                       with_stats: bool = True,
602                       with_profile: bool = False,
603                       extra_args: List[str] = None):
604        if extra_args is None:
605            extra_args = []
606        extra_args.extend([
607            '--ssl-reqd',
608        ])
609        return self.ftp_upload(urls=urls, fupload=fupload,
610                               with_stats=with_stats, with_profile=with_profile,
611                               extra_args=extra_args)
612
613    def response_file(self, idx: int):
614        return os.path.join(self._run_dir, f'download_{idx}.data')
615
616    def run_direct(self, args, with_stats: bool = False, with_profile: bool = False):
617        my_args = [self._curl]
618        if with_stats:
619            my_args.extend([
620                '-w', '%{json}\\n'
621            ])
622        my_args.extend([
623            '-o', 'download.data',
624        ])
625        my_args.extend(args)
626        return self._run(args=my_args, with_stats=with_stats, with_profile=with_profile)
627
628    def _run(self, args, intext='', with_stats: bool = False, with_profile: bool = True):
629        self._rmf(self._stdoutfile)
630        self._rmf(self._stderrfile)
631        self._rmf(self._headerfile)
632        started_at = datetime.now()
633        exception = None
634        profile = None
635        started_at = datetime.now()
636        try:
637            with open(self._stdoutfile, 'w') as cout:
638                with open(self._stderrfile, 'w') as cerr:
639                    if with_profile:
640                        end_at = started_at + timedelta(seconds=self._timeout) \
641                            if self._timeout else None
642                        log.info(f'starting: {args}')
643                        p = subprocess.Popen(args, stderr=cerr, stdout=cout,
644                                             cwd=self._run_dir, shell=False)
645                        profile = RunProfile(p.pid, started_at, self._run_dir)
646                        if intext is not None and False:
647                            p.communicate(input=intext.encode(), timeout=1)
648                        ptimeout = 0.0
649                        while True:
650                            try:
651                                p.wait(timeout=ptimeout)
652                                break
653                            except subprocess.TimeoutExpired:
654                                if end_at and datetime.now() >= end_at:
655                                    p.kill()
656                                    raise subprocess.TimeoutExpired(cmd=args, timeout=self._timeout)
657                                profile.sample()
658                                ptimeout = 0.01
659                        exitcode = p.returncode
660                        profile.finish()
661                        log.info(f'done: exit={exitcode}, profile={profile}')
662                    else:
663                        p = subprocess.run(args, stderr=cerr, stdout=cout,
664                                           cwd=self._run_dir, shell=False,
665                                           input=intext.encode() if intext else None,
666                                           timeout=self._timeout)
667                        exitcode = p.returncode
668        except subprocess.TimeoutExpired:
669            now = datetime.now()
670            duration = now - started_at
671            log.warning(f'Timeout at {now} after {duration.total_seconds()}s '
672                        f'(configured {self._timeout}s): {args}')
673            exitcode = -1
674            exception = 'TimeoutExpired'
675        coutput = open(self._stdoutfile).readlines()
676        cerrput = open(self._stderrfile).readlines()
677        return ExecResult(args=args, exit_code=exitcode, exception=exception,
678                          stdout=coutput, stderr=cerrput,
679                          duration=datetime.now() - started_at,
680                          with_stats=with_stats,
681                          profile=profile)
682
683    def _raw(self, urls, intext='', timeout=None, options=None, insecure=False,
684             alpn_proto: Optional[str] = None,
685             force_resolve=True,
686             with_stats=False,
687             with_headers=True,
688             def_tracing=True,
689             with_profile=False):
690        args = self._complete_args(
691            urls=urls, timeout=timeout, options=options, insecure=insecure,
692            alpn_proto=alpn_proto, force_resolve=force_resolve,
693            with_headers=with_headers, def_tracing=def_tracing)
694        r = self._run(args, intext=intext, with_stats=with_stats,
695                      with_profile=with_profile)
696        if r.exit_code == 0 and with_headers:
697            self._parse_headerfile(self._headerfile, r=r)
698            if r.json:
699                r.response["json"] = r.json
700        return r
701
702    def _complete_args(self, urls, timeout=None, options=None,
703                       insecure=False, force_resolve=True,
704                       alpn_proto: Optional[str] = None,
705                       with_headers: bool = True,
706                       def_tracing: bool = True):
707        if not isinstance(urls, list):
708            urls = [urls]
709
710        args = [self._curl, "-s", "--path-as-is"]
711        if with_headers:
712            args.extend(["-D", self._headerfile])
713        if def_tracing is not False and not self._silent:
714            args.extend(['-v', '--trace-ids', '--trace-time'])
715            if self.env.verbose > 1:
716                args.extend(['--trace-config', 'http/2,http/3,h2-proxy,h1-proxy'])
717                pass
718
719        active_options = options
720        if options is not None and '--next' in options:
721            active_options = options[options.index('--next') + 1:]
722
723        for url in urls:
724            u = urlparse(urls[0])
725            if options:
726                args.extend(options)
727            if alpn_proto is not None:
728                if alpn_proto not in self.ALPN_ARG:
729                    raise Exception(f'unknown ALPN protocol: "{alpn_proto}"')
730                args.append(self.ALPN_ARG[alpn_proto])
731
732            if u.scheme == 'http':
733                pass
734            elif insecure:
735                args.append('--insecure')
736            elif active_options and "--cacert" in active_options:
737                pass
738            elif u.hostname:
739                args.extend(["--cacert", self.env.ca.cert_file])
740
741            if force_resolve and u.hostname and u.hostname != 'localhost' \
742                    and not re.match(r'^(\d+|\[|:).*', u.hostname):
743                port = u.port if u.port else 443
744                args.extend(["--resolve", f"{u.hostname}:{port}:127.0.0.1"])
745            if timeout is not None and int(timeout) > 0:
746                args.extend(["--connect-timeout", str(int(timeout))])
747            args.append(url)
748        return args
749
750    def _parse_headerfile(self, headerfile: str, r: ExecResult = None) -> ExecResult:
751        lines = open(headerfile).readlines()
752        if r is None:
753            r = ExecResult(args=[], exit_code=0, stdout=[], stderr=[])
754
755        response = None
756
757        def fin_response(resp):
758            if resp:
759                r.add_response(resp)
760
761        expected = ['status']
762        for line in lines:
763            line = line.strip()
764            if re.match(r'^$', line):
765                if 'trailer' in expected:
766                    # end of trailers
767                    fin_response(response)
768                    response = None
769                    expected = ['status']
770                elif 'header' in expected:
771                    # end of header, another status or trailers might follow
772                    expected = ['status', 'trailer']
773                else:
774                    assert False, f"unexpected line: '{line}'"
775                continue
776            if 'status' in expected:
777                # log.debug("reading 1st response line: %s", line)
778                m = re.match(r'^(\S+) (\d+)( .*)?$', line)
779                if m:
780                    fin_response(response)
781                    response = {
782                        "protocol": m.group(1),
783                        "status": int(m.group(2)),
784                        "description": m.group(3),
785                        "header": {},
786                        "trailer": {},
787                        "body": r.outraw
788                    }
789                    expected = ['header']
790                    continue
791            if 'trailer' in expected:
792                m = re.match(r'^([^:]+):\s*(.*)$', line)
793                if m:
794                    response['trailer'][m.group(1).lower()] = m.group(2)
795                    continue
796            if 'header' in expected:
797                m = re.match(r'^([^:]+):\s*(.*)$', line)
798                if m:
799                    response['header'][m.group(1).lower()] = m.group(2)
800                    continue
801            assert False, f"unexpected line: '{line}, expected: {expected}'"
802
803        fin_response(response)
804        return r
805