pybrws 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. pybrws/__init__.py +3 -0
  2. pybrws/__main__.py +3 -0
  3. pybrws/certs.py +275 -0
  4. pybrws/cli.py +303 -0
  5. pybrws/client.py +303 -0
  6. pybrws/cookies.py +234 -0
  7. pybrws/data/__init__.py +21 -0
  8. pybrws/data/chrome-1.hex +61 -0
  9. pybrws/data/reference-firefox-157-peet.json +47 -0
  10. pybrws/data/reference-firefox-157-windows-fresh.hex +60 -0
  11. pybrws/differ.py +138 -0
  12. pybrws/emitter.py +366 -0
  13. pybrws/h3.py +341 -0
  14. pybrws/handshake.py +814 -0
  15. pybrws/headers.py +171 -0
  16. pybrws/hexio.py +58 -0
  17. pybrws/hosts.py +49 -0
  18. pybrws/http1.py +268 -0
  19. pybrws/http2.py +761 -0
  20. pybrws/keyexchange.py +107 -0
  21. pybrws/keyschedule.py +185 -0
  22. pybrws/message.py +348 -0
  23. pybrws/model.py +125 -0
  24. pybrws/parser.py +123 -0
  25. pybrws/pool.py +159 -0
  26. pybrws/profiles/__init__.py +166 -0
  27. pybrws/profiles/chrome155.py +579 -0
  28. pybrws/profiles/firefox157.py +318 -0
  29. pybrws/profiles/firefox157_quic.py +311 -0
  30. pybrws/proxies.py +277 -0
  31. pybrws/proxy.py +407 -0
  32. pybrws/quic.py +454 -0
  33. pybrws/quic_impersonate.py +510 -0
  34. pybrws/records.py +161 -0
  35. pybrws/session.py +305 -0
  36. pybrws/tickets.py +122 -0
  37. pybrws/tls12.py +374 -0
  38. pybrws/tools/__init__.py +7 -0
  39. pybrws/tools/_selfsigned.py +93 -0
  40. pybrws/tools/ab_test.py +496 -0
  41. pybrws/tools/catch_h2.py +382 -0
  42. pybrws/tools/catch_hello.py +218 -0
  43. pybrws/tools/catch_quic.py +231 -0
  44. pybrws/tools/catch_request.py +288 -0
  45. pybrws/tools/catch_resumed.py +285 -0
  46. pybrws/tools/detect_waf.py +165 -0
  47. pybrws/tools/isolate.py +117 -0
  48. pybrws/tools/ja4.py +205 -0
  49. pybrws/tools/probe_fingerprint.py +199 -0
  50. pybrws/tools/read_quic.py +458 -0
  51. pybrws/tools/verify.py +664 -0
  52. pybrws/wire.py +27 -0
  53. pybrws-0.1.0.dist-info/METADATA +86 -0
  54. pybrws-0.1.0.dist-info/RECORD +58 -0
  55. pybrws-0.1.0.dist-info/WHEEL +5 -0
  56. pybrws-0.1.0.dist-info/entry_points.txt +2 -0
  57. pybrws-0.1.0.dist-info/licenses/LICENSE +21 -0
  58. pybrws-0.1.0.dist-info/top_level.txt +1 -0
pybrws/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pybrws - a from-scratch browser-fingerprinted TLS client."""
2
+
3
+ __version__ = "0.1.0"
pybrws/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
pybrws/certs.py ADDED
@@ -0,0 +1,275 @@
1
+ """Certificate handling: parsing, decompression, chain and signature checks.
2
+
3
+ Path validation is delegated to `cryptography.x509.verification`. Hand-rolling
4
+ it would mean reimplementing name constraints, policy handling and revocation
5
+ semantics, which is the kind of code that is wrong in ways nobody notices
6
+ until it matters.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import ssl
13
+ import warnings
14
+
15
+ from cryptography import x509
16
+ from cryptography.exceptions import InvalidSignature
17
+ from cryptography.hazmat.primitives import hashes
18
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
19
+ from cryptography.x509.verification import PolicyBuilder, Store
20
+
21
+ from . import hosts
22
+ from .parser import _Reader
23
+
24
+ # RFC 8879
25
+ COMPRESSION_ZLIB = 1
26
+ COMPRESSION_BROTLI = 2
27
+ COMPRESSION_ZSTD = 3
28
+
29
+ # The string a TLS 1.3 server signs, RFC 8446 section 4.4.3.
30
+ SIGNATURE_CONTEXT = b" " * 64 + b"TLS 1.3, server CertificateVerify" + b"\x00"
31
+
32
+ RSA_PSS = {
33
+ 0x0804: hashes.SHA256,
34
+ 0x0805: hashes.SHA384,
35
+ 0x0806: hashes.SHA512,
36
+ }
37
+ ECDSA = {
38
+ 0x0403: hashes.SHA256,
39
+ 0x0503: hashes.SHA384,
40
+ 0x0603: hashes.SHA512,
41
+ }
42
+ RSA_PKCS1 = {
43
+ 0x0401: hashes.SHA256,
44
+ 0x0501: hashes.SHA384,
45
+ 0x0601: hashes.SHA512,
46
+ }
47
+ ED25519 = 0x0807
48
+
49
+
50
+ class CertificateError(Exception):
51
+ pass
52
+
53
+
54
+ def decompress_certificate(message: bytes) -> bytes:
55
+ """Turn an RFC 8879 CompressedCertificate body into a Certificate body.
56
+
57
+ The output is bounded by the declared length *while* decompressing.
58
+ Checking it afterwards would be too late: this message arrives before the
59
+ server has proved anything, and a kilobyte of brotli can expand to a
60
+ gigabyte. RFC 8879 section 4 asks for exactly this bound. The declared
61
+ length is a uint24, so its own ceiling of 2**24 - 1 holds by construction.
62
+ """
63
+ reader = _Reader(message, "CompressedCertificate")
64
+ algorithm = reader.uint(2)
65
+ expected = reader.uint(3)
66
+ compressed = reader.vector(3)
67
+ # One byte past the declared length is enough to know it was a lie.
68
+ limit = expected + 1
69
+
70
+ try:
71
+ if algorithm == COMPRESSION_ZLIB:
72
+ import zlib
73
+
74
+ plain = zlib.decompressobj().decompress(compressed, limit)
75
+ elif algorithm == COMPRESSION_BROTLI:
76
+ import brotli
77
+
78
+ # Bounded to within one internal block of the limit, which is
79
+ # enough: the length check below rejects anything past it.
80
+ plain = brotli.Decompressor().process(
81
+ compressed, output_buffer_limit=limit)
82
+ elif algorithm == COMPRESSION_ZSTD:
83
+ try:
84
+ from compression import zstd
85
+ except ImportError:
86
+ import io
87
+
88
+ import zstandard
89
+
90
+ with zstandard.ZstdDecompressor().stream_reader(
91
+ io.BytesIO(compressed)) as source:
92
+ plain = source.read(limit)
93
+ else:
94
+ plain = zstd.ZstdDecompressor().decompress(
95
+ compressed, max_length=limit)
96
+ else:
97
+ raise CertificateError(
98
+ "unknown certificate compression " + str(algorithm))
99
+ except CertificateError:
100
+ raise
101
+ except Exception as exc:
102
+ raise CertificateError(
103
+ "certificate decompression failed: " + str(exc)) from exc
104
+
105
+ if len(plain) != expected:
106
+ raise CertificateError(
107
+ "decompressed to %d bytes, the server said %d" % (len(plain), expected)
108
+ )
109
+ return plain
110
+
111
+
112
+ def parse_certificate(body: bytes) -> list[bytes]:
113
+ """Return the DER certificates in a Certificate message body, leaf first."""
114
+ reader = _Reader(body, "Certificate")
115
+ reader.vector(1) # certificate_request_context, empty for a server
116
+ entries = _Reader(reader.vector(3), "certificate_list")
117
+ chain = []
118
+ while entries.remaining:
119
+ chain.append(entries.vector(3))
120
+ entries.vector(2) # per-certificate extensions
121
+ if not chain:
122
+ raise CertificateError("the server sent an empty certificate list")
123
+ return chain
124
+
125
+
126
+ def _parse_anchors(ders) -> list:
127
+ anchors = []
128
+ for der in ders:
129
+ try:
130
+ anchors.append(x509.load_der_x509_certificate(der))
131
+ except Exception:
132
+ # Bundles carry the occasional malformed anchor. One
133
+ # unparseable certificate should not cost the other few hundred.
134
+ continue
135
+ return anchors
136
+
137
+
138
+ def _platform_roots() -> list[bytes]:
139
+ context = ssl.create_default_context()
140
+ return list(context.get_ca_certs(binary_form=True))
141
+
142
+
143
+ def _mozilla_roots() -> list[bytes]:
144
+ """The DER of each certificate in the certifi bundle, which is Mozilla's
145
+ root program. A bundle that cannot be read counts as having none."""
146
+ import certifi
147
+
148
+ try:
149
+ with open(certifi.where(), encoding="ascii") as bundle:
150
+ text = bundle.read()
151
+ except (OSError, UnicodeDecodeError):
152
+ return []
153
+ begin, end = "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"
154
+ ders = []
155
+ for block in text.split(end)[:-1]:
156
+ # The bundle puts a comment above each certificate.
157
+ _, found, body = block.partition(begin)
158
+ if not found:
159
+ continue
160
+ try:
161
+ ders.append(ssl.PEM_cert_to_DER_cert(begin + body + end + "\n"))
162
+ except ValueError:
163
+ continue
164
+ return ders
165
+
166
+
167
+ def system_store() -> Store:
168
+ """The platform's trust anchors alone, as Python's ssl module sees them.
169
+
170
+ Raises:
171
+ CertificateError: if the platform has none.
172
+ """
173
+ warnings.filterwarnings("ignore", message="Parsed a serial number")
174
+ anchors = _parse_anchors(_platform_roots())
175
+ if not anchors:
176
+ raise CertificateError("no trust anchors available from the system store")
177
+ return Store(anchors)
178
+
179
+
180
+ def default_store() -> Store:
181
+ """The roots a connection is verified against by default: Mozilla's
182
+ (the certifi bundle) plus whatever the platform adds, an enterprise CA
183
+ for example. That is close to Firefox's mix: Firefox adds only the roots
184
+ an administrator or user installed on the system, not the platform's
185
+ whole root program.
186
+
187
+ A certificate that is in both counts once. A source that cannot be read
188
+ counts as having none.
189
+
190
+ Raises:
191
+ CertificateError: if both sources yield no anchors.
192
+ """
193
+ warnings.filterwarnings("ignore", message="Parsed a serial number")
194
+ seen = set()
195
+ unique = []
196
+ for der in _mozilla_roots() + _platform_roots():
197
+ if der not in seen:
198
+ seen.add(der)
199
+ unique.append(der)
200
+ anchors = _parse_anchors(unique)
201
+ if not anchors:
202
+ raise CertificateError("no trust anchors available from certifi or the system store")
203
+ return Store(anchors)
204
+
205
+
206
+ def verify_chain(chain: list[bytes], hostname: str, store: Store | None = None):
207
+ """Validate the chain for `hostname`, returning the leaf certificate.
208
+
209
+ Raises:
210
+ CertificateError: if the chain does not validate.
211
+ """
212
+ certificates = [x509.load_der_x509_certificate(der) for der in chain]
213
+ leaf, intermediates = certificates[0], certificates[1:]
214
+ if hosts.is_ip_literal(hostname):
215
+ # An address is matched against an IP address SAN, never a DNS one.
216
+ subject = x509.IPAddress(ipaddress.ip_address(hostname.strip("[]")))
217
+ else:
218
+ subject = x509.DNSName(hostname)
219
+ verifier = (
220
+ PolicyBuilder()
221
+ .store(store if store is not None else default_store())
222
+ .build_server_verifier(subject)
223
+ )
224
+ try:
225
+ verifier.verify(leaf, intermediates)
226
+ except Exception as exc:
227
+ raise CertificateError("certificate chain rejected: " + str(exc)) from exc
228
+ return leaf
229
+
230
+
231
+ def verify_signature(leaf, scheme: int, signature: bytes, transcript_hash: bytes) -> None:
232
+ """Check the CertificateVerify signature over the handshake transcript.
233
+
234
+ This is what proves the peer holds the key in the certificate, rather
235
+ than merely having a copy of someone else's chain.
236
+ """
237
+ signed = SIGNATURE_CONTEXT + transcript_hash
238
+ key = leaf.public_key()
239
+ try:
240
+ if scheme in RSA_PSS:
241
+ algorithm = RSA_PSS[scheme]()
242
+ key.verify(signature, signed,
243
+ padding.PSS(mgf=padding.MGF1(algorithm),
244
+ salt_length=algorithm.digest_size),
245
+ algorithm)
246
+ elif scheme in ECDSA:
247
+ key.verify(signature, signed, ec.ECDSA(ECDSA[scheme]()))
248
+ elif scheme == ED25519:
249
+ key.verify(signature, signed)
250
+ elif scheme in RSA_PKCS1:
251
+ # Not permitted for TLS 1.3 CertificateVerify, but accepting it
252
+ # silently would hide a server doing the wrong thing.
253
+ raise CertificateError(
254
+ "server signed with rsa_pkcs1, which TLS 1.3 forbids here"
255
+ )
256
+ else:
257
+ raise CertificateError("unsupported signature scheme 0x%04x" % scheme)
258
+ except InvalidSignature as exc:
259
+ raise CertificateError("CertificateVerify signature is invalid") from exc
260
+ except CertificateError:
261
+ raise
262
+ except Exception as exc:
263
+ raise CertificateError("CertificateVerify could not be checked: "
264
+ + str(exc)) from exc
265
+
266
+
267
+ def expected_key_type(leaf) -> str:
268
+ key = leaf.public_key()
269
+ if isinstance(key, rsa.RSAPublicKey):
270
+ return "rsa"
271
+ if isinstance(key, ec.EllipticCurvePublicKey):
272
+ return "ecdsa"
273
+ if isinstance(key, ed25519.Ed25519PublicKey):
274
+ return "ed25519"
275
+ return type(key).__name__
pybrws/cli.py ADDED
@@ -0,0 +1,303 @@
1
+ """Command line front end, shaped like curl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.parse import urljoin
9
+
10
+ from . import profiles
11
+ from . import headers as header_tools
12
+ from .client import (REDIRECT_STATUSES, TooManyRedirects, UnsupportedUrl,
13
+ Unmeasured, redirect_headers, request)
14
+ from .message import DEFAULT_MAX_BODY, ContentEncodingError, Response
15
+ from .certs import CertificateError
16
+ from .h3 import Http3Error
17
+ from .handshake import HandshakeError, TruncatedStream
18
+ from .http1 import Http1Error
19
+ from .http2 import Http2Error
20
+ from .proxies import ProxyError, parse_proxy
21
+ from .quic_impersonate import NotInstalled
22
+ from .records import TlsAlert, UnexpectedRecord
23
+ from .tls12 import Tls12Error
24
+
25
+ # What the library raises for a server, a response, a profile or an input it
26
+ # will not take: reported in one line, as curl does, not as a traceback.
27
+ REPORTED = (UnsupportedUrl, TooManyRedirects, TlsAlert, ProxyError, CertificateError,
28
+ Unmeasured, ContentEncodingError, Http1Error, Http2Error, Http3Error,
29
+ HandshakeError, Tls12Error, UnexpectedRecord, TruncatedStream,
30
+ UnicodeError, profiles.ProfileError, NotInstalled)
31
+
32
+
33
+ # `pybrws <name> ...` runs one of the method's tools; each module has
34
+ # main(argv) -> int. `capture` takes a second word, the kind of capture.
35
+ SUBCOMMANDS = {
36
+ "read-quic": "pybrws.tools.read_quic",
37
+ "ja4": "pybrws.tools.ja4",
38
+ "verify": "pybrws.tools.verify",
39
+ "probe": "pybrws.tools.probe_fingerprint",
40
+ "ab": "pybrws.tools.ab_test",
41
+ "isolate": "pybrws.tools.isolate",
42
+ "waf": "pybrws.tools.detect_waf",
43
+ }
44
+ CAPTURE_KINDS = {
45
+ "hello": "pybrws.tools.catch_hello",
46
+ "request": "pybrws.tools.catch_request",
47
+ "h2": "pybrws.tools.catch_h2",
48
+ "quic": "pybrws.tools.catch_quic",
49
+ "resumed": "pybrws.tools.catch_resumed",
50
+ }
51
+ # The optional dependency to name when an import of it fails.
52
+ EXTRA_FOR = {"httpx": "measure", "aioquic": "http3"}
53
+
54
+
55
+ def missing_extra(error: ModuleNotFoundError, what: str):
56
+ """SystemExit naming the extra to install, or None for any other module."""
57
+ extra = EXTRA_FOR.get((error.name or "").split(".")[0])
58
+ if extra is None:
59
+ return None
60
+ return SystemExit("pybrws %s needs %s: pip install 'pybrws[%s]'"
61
+ % (what, error.name.split(".")[0], extra))
62
+
63
+
64
+ def run_tool(module_name: str, argv: list[str], what: str) -> int:
65
+ import importlib
66
+
67
+ try:
68
+ return importlib.import_module(module_name).main(argv)
69
+ except ModuleNotFoundError as error:
70
+ exit_ = missing_extra(error, what)
71
+ if exit_ is None:
72
+ raise
73
+ raise exit_ from None
74
+
75
+
76
+ def dispatch(argv: list[str]) -> int | None:
77
+ """Run a subcommand if `argv` names one; None means it is a fetch."""
78
+ if not argv:
79
+ return None
80
+ if argv[0] in SUBCOMMANDS:
81
+ return run_tool(SUBCOMMANDS[argv[0]], argv[1:], argv[0])
82
+ if argv[0] == "capture":
83
+ kinds = ", ".join(CAPTURE_KINDS)
84
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
85
+ print("usage: pybrws capture KIND [options]\n\nKIND is one of: " + kinds)
86
+ return 0
87
+ if argv[1] not in CAPTURE_KINDS:
88
+ raise SystemExit("pybrws capture: unknown kind %r; one of: %s"
89
+ % (argv[1], kinds))
90
+ return run_tool(CAPTURE_KINDS[argv[1]], argv[2:], "capture " + argv[1])
91
+ return None
92
+
93
+
94
+ def parse_header(raw: str) -> tuple[str, str]:
95
+ name, separator, value = raw.partition(":")
96
+ if not separator:
97
+ raise argparse.ArgumentTypeError("expected 'Name: value', got " + repr(raw))
98
+ return name.strip(), value.strip()
99
+
100
+
101
+ def resolve_profile(name: str, http3: bool):
102
+ """The profile for the transport actually being used."""
103
+ try:
104
+ return profiles.load(name, transport="quic" if http3 else "tcp")
105
+ except profiles.WrongTransport as error:
106
+ # Only this front end has --http3, so only it can name the flag.
107
+ raise SystemExit(str(error) + ("; add --http3" if error.has == "quic"
108
+ else "; drop --http3"))
109
+ except profiles.ProfileError as error:
110
+ raise SystemExit(str(error))
111
+
112
+
113
+ def fetch_http3(args, profile, overrides) -> Response:
114
+ """One HTTP/3 fetch, following redirects by hand.
115
+
116
+ `pybrws.h3` returns the response it gets and does not follow anything,
117
+ so the loop lives here. Without it `-L` would silently mean nothing on
118
+ this path while meaning something on the other -- the same arm
119
+ answering a different question, which is how a comparison between the
120
+ two transports goes wrong.
121
+ """
122
+ from . import h3
123
+
124
+ url = args.url
125
+ history: list[Response] = []
126
+ budget = args.max_redirs if args.location else 0
127
+ for hop in range(budget + 1):
128
+ # The method and body asked for: h3 refuses what it has no
129
+ # capture for, where this used to send a GET whatever -X and -d said.
130
+ response = h3.request(url, method=args.request.upper(), data=args.data,
131
+ timeout=args.timeout, headers=overrides or None,
132
+ profile=profile, verify=not args.insecure,
133
+ max_body_size=getattr(args, "max_body_size",
134
+ DEFAULT_MAX_BODY))
135
+ response.url = url
136
+ location = response.header("location")
137
+ # The statuses client.request follows, so both arms agree on what
138
+ # a redirect is; a 304 with a Location header is not one.
139
+ if response.status not in REDIRECT_STATUSES or not location:
140
+ break
141
+ if hop == budget:
142
+ if budget == 0:
143
+ break # --no-location: the redirect is the answer, as over TCP
144
+ raise TooManyRedirects("still redirecting after %d hops, last %r"
145
+ % (budget, urljoin(url, location)))
146
+ history.append(response)
147
+ next_url = urljoin(url, location)
148
+ overrides = redirect_headers(overrides, url, next_url)
149
+ url = next_url
150
+ response.history = history
151
+ return response
152
+
153
+
154
+ def describe_request(profile, args) -> str:
155
+ lines = ["> " + args.url,
156
+ "> transport: " + ("quic/h3" if args.http3 else "tcp"),
157
+ "> profile: " + profile.NAME]
158
+ if args.proxy:
159
+ # str(Proxy) omits the password: -v output gets pasted into issues.
160
+ lines.append("> proxy: " + str(parse_proxy(args.proxy)))
161
+ for name, value in args.header:
162
+ lines.append("> override: " + name + ": " + value)
163
+ return "\n".join(lines)
164
+
165
+
166
+ def render_head(response: Response) -> str:
167
+ version = response.http_version or "h2"
168
+ lines = [version + " " + str(response.status)]
169
+ lines += [name + ": " + value for name, value in response.headers]
170
+ return "\n".join(lines)
171
+
172
+
173
+ def build_parser() -> argparse.ArgumentParser:
174
+ parser = argparse.ArgumentParser(
175
+ prog="pybrws",
176
+ description="Fetch an https URL with a browser's protocol fingerprint.",
177
+ epilog="subcommands (pybrws <name> --help): capture {%s}, %s"
178
+ % (",".join(CAPTURE_KINDS), ", ".join(SUBCOMMANDS)),
179
+ )
180
+ parser.add_argument("url", help="the https URL to fetch")
181
+ parser.add_argument("-X", "--request", default=None, metavar="METHOD",
182
+ help="HTTP method; defaults to GET, or POST with -d")
183
+ parser.add_argument("-d", "--data", metavar="BODY",
184
+ help="request body; implies POST unless -X says otherwise")
185
+ parser.add_argument("-H", "--header", action="append", default=[],
186
+ type=parse_header, metavar="'Name: value'",
187
+ help="replace or add a request header, repeatable")
188
+ parser.add_argument("-o", "--output", metavar="FILE",
189
+ help="write the body to FILE instead of stdout")
190
+ parser.add_argument("-i", "--include", action="store_true",
191
+ help="print the response head before the body")
192
+ parser.add_argument("-I", "--head-only", action="store_true",
193
+ help="print the response head and not the body")
194
+ # curl needs -L; a browser always follows, and this tool imitates a
195
+ # browser. --no-location restores curl's behaviour.
196
+ parser.add_argument("-L", "--location", action="store_true", default=True,
197
+ help="follow redirects (the default)")
198
+ parser.add_argument("--no-location", dest="location", action="store_false",
199
+ help="return the redirect instead of following it")
200
+ parser.add_argument("--max-redirs", type=int, default=5, metavar="N")
201
+ parser.add_argument("-v", "--verbose", action="store_true",
202
+ help="describe the request on stderr")
203
+ parser.add_argument("-s", "--silent", action="store_true",
204
+ help="suppress the status line on stderr")
205
+ parser.add_argument("-x", "--proxy", metavar="URL",
206
+ help="http://, socks5:// or socks5h://, with optional "
207
+ "user:pass@")
208
+ parser.add_argument("-k", "--insecure", action="store_true",
209
+ help="skip certificate verification, as curl's -k does")
210
+ parser.add_argument("--profile", default=None, metavar="NAME",
211
+ help="firefox157 or chrome155 over TCP, "
212
+ "firefox157_quic with --http3; or a .py "
213
+ "profile file")
214
+ parser.add_argument("--http3", action="store_true",
215
+ help="fetch over HTTP/3 instead of TCP. A separate "
216
+ "profile and a separate code path, not a "
217
+ "negotiation")
218
+ parser.add_argument("--timeout", type=float, default=20.0, metavar="SECONDS")
219
+ parser.add_argument("--max-filesize", type=int, default=None, metavar="BYTES",
220
+ help="the most a response body may be, received or "
221
+ "decoded (default 128 MiB); 0 lifts the cap")
222
+ return parser
223
+
224
+
225
+ def main(argv: list[str] | None = None) -> int:
226
+ if argv is None:
227
+ argv = sys.argv[1:]
228
+ done = dispatch(list(argv))
229
+ if done is not None:
230
+ return done
231
+ args = build_parser().parse_args(argv)
232
+ if args.request is None:
233
+ args.request = "POST" if args.data else "GET"
234
+ if args.max_filesize is None:
235
+ args.max_body_size = DEFAULT_MAX_BODY
236
+ else:
237
+ args.max_body_size = args.max_filesize or None # 0: no cap
238
+ overrides = list(args.header)
239
+ if args.data and not header_tools.has(overrides, "content-type"):
240
+ # curl's default for -d, and a server will otherwise guess.
241
+ overrides.append(("Content-Type", "application/x-www-form-urlencoded"))
242
+
243
+ if args.profile is None:
244
+ args.profile = "firefox157_quic" if args.http3 else "firefox157"
245
+ profile = resolve_profile(args.profile, args.http3)
246
+ if args.http3 and args.proxy:
247
+ raise SystemExit("--proxy does not apply to --http3: QUIC is UDP and "
248
+ "this client has no UDP proxy support")
249
+
250
+ try:
251
+ if args.verbose:
252
+ # Inside the try: describing the proxy parses it, and a proxy
253
+ # that does not parse is the ProxyError the request would raise.
254
+ print(describe_request(profile, args), file=sys.stderr)
255
+ if args.http3:
256
+ response = fetch_http3(args, profile, overrides)
257
+ else:
258
+ response = request(
259
+ args.url,
260
+ method=args.request.upper(),
261
+ headers=overrides or None,
262
+ data=args.data,
263
+ profile=profile,
264
+ timeout=args.timeout,
265
+ follow_redirects=args.max_redirs if args.location else 0,
266
+ proxy=args.proxy,
267
+ verify=not args.insecure,
268
+ max_body_size=args.max_body_size,
269
+ )
270
+ except ModuleNotFoundError as exc:
271
+ exit_ = missing_extra(exc, "--http3")
272
+ if exit_ is None:
273
+ raise
274
+ raise exit_ from None
275
+ except REPORTED as exc:
276
+ raise SystemExit(type(exc).__name__ + ": " + str(exc))
277
+ except OSError as exc:
278
+ raise SystemExit("connection failed: " + str(exc))
279
+
280
+ if not args.silent:
281
+ for hop in response.history:
282
+ print("* " + str(hop.status) + " -> " + (hop.header("location") or ""),
283
+ file=sys.stderr)
284
+ print("* " + str(response.status) + " " + (response.http_version or "h2")
285
+ + " " + str(len(response.body)) + " bytes", file=sys.stderr)
286
+
287
+ if args.include or args.head_only:
288
+ print(render_head(response))
289
+ if not args.head_only:
290
+ print()
291
+
292
+ if not args.head_only:
293
+ if args.output:
294
+ Path(args.output).write_bytes(response.body)
295
+ else:
296
+ sys.stdout.buffer.write(response.body)
297
+ sys.stdout.buffer.flush()
298
+
299
+ return 0 if response.status < 400 else 1
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())