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