chunkedte 0.2.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.
chunkedte/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Strict, incremental, typed, zero-dependency codec for HTTP/1.1 chunked transfer coding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._decode import Decoder, decode
6
+ from ._encode import Encoder, encode
7
+ from ._errors import ChunkedError, LimitError
8
+ from ._framing import is_chunked
9
+ from ._limits import Limits
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = [
14
+ "ChunkedError",
15
+ "Decoder",
16
+ "Encoder",
17
+ "LimitError",
18
+ "Limits",
19
+ "decode",
20
+ "encode",
21
+ "is_chunked",
22
+ ]
chunkedte/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m chunkedte``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
chunkedte/_cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """Command line front end: ``chunkedte decode`` and ``chunkedte encode`` over files or stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import BinaryIO, TextIO
8
+
9
+ from ._decode import Decoder
10
+ from ._encode import DEFAULT_CHUNK_SIZE, Encoder, encode
11
+ from ._errors import ChunkedError
12
+
13
+ _BLOCK = 65536
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="chunkedte", description="Decode or encode HTTP/1.1 chunked transfer coding."
19
+ )
20
+ commands = parser.add_subparsers(dest="command", required=True)
21
+ decode = commands.add_parser("decode", help="print the body; trailers go to stderr")
22
+ decode.add_argument("file", nargs="?", help="input file (default: stdin)")
23
+ encode = commands.add_parser("encode", help="frame the input as a chunked body")
24
+ encode.add_argument("file", nargs="?", help="input file (default: stdin)")
25
+ encode.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE)
26
+ encode.add_argument(
27
+ "--trailer", action="append", default=[], metavar="NAME:VALUE", help="repeatable"
28
+ )
29
+ return parser
30
+
31
+
32
+ def main(
33
+ argv: list[str] | None = None,
34
+ stdin: BinaryIO | None = None,
35
+ stdout: BinaryIO | None = None,
36
+ stderr: TextIO | None = None,
37
+ ) -> int:
38
+ """Run the command line tool and return its exit status."""
39
+ args = _parser().parse_args(argv)
40
+ out = sys.stdout.buffer if stdout is None else stdout
41
+ err = sys.stderr if stderr is None else stderr
42
+ source = (sys.stdin.buffer if stdin is None else stdin) if args.file is None else None
43
+ try:
44
+ if source is None:
45
+ with open(args.file, "rb") as handle:
46
+ return _run(args, handle, out, err)
47
+ return _run(args, source, out, err)
48
+ except (ChunkedError, ValueError, OSError) as error:
49
+ print(f"chunkedte: {error}", file=err)
50
+ return 1
51
+
52
+
53
+ def _run(args: argparse.Namespace, source: BinaryIO, out: BinaryIO, err: TextIO) -> int:
54
+ if args.command == "decode":
55
+ return _decode(source, out, err)
56
+ if args.chunk_size <= 0:
57
+ raise ValueError("--chunk-size must be positive")
58
+ trailers = [_split_trailer(text) for text in args.trailer]
59
+ closing = encode(b"", trailers=trailers) # validates the trailers before any output
60
+ encoder = Encoder()
61
+ while block := source.read(args.chunk_size):
62
+ out.write(encoder.write(block))
63
+ out.write(closing)
64
+ return 0
65
+
66
+
67
+ def _decode(source: BinaryIO, out: BinaryIO, err: TextIO) -> int:
68
+ decoder = Decoder()
69
+ while not decoder.is_complete and (block := source.read(_BLOCK)):
70
+ for piece in decoder.feed(block):
71
+ out.write(piece)
72
+ if not decoder.is_complete:
73
+ raise ChunkedError("incomplete chunked message")
74
+ if decoder.unused_data or source.read(1):
75
+ raise ChunkedError("bytes after the end of the chunked message")
76
+ for name, value in decoder.trailers:
77
+ # Escape obs-text and C1 bytes so any terminal encoding can print them.
78
+ safe = value.encode("latin-1").decode("ascii", "backslashreplace")
79
+ print(f"{name}: {safe}", file=err)
80
+ return 0
81
+
82
+
83
+ def _split_trailer(text: str) -> tuple[str, str]:
84
+ name, colon, value = text.partition(":")
85
+ if not colon:
86
+ raise ValueError(f"--trailer needs NAME:VALUE, got {text!r}")
87
+ return name, value.strip(" \t")
chunkedte/_decode.py ADDED
@@ -0,0 +1,177 @@
1
+ """Incremental, strict decoder for HTTP/1.1 chunked transfer coding (RFC 9112 section 7.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+ from ._errors import ChunkedError, LimitError
8
+ from ._grammar import SIZE_LINE, parse_field_line
9
+ from ._limits import Limits
10
+
11
+ _CR = 0x0D
12
+ # Sixteen significant hex digits hold any value below 2**64; more is never a real size.
13
+ _MAX_SIZE_DIGITS = 16
14
+
15
+
16
+ class _State(enum.Enum):
17
+ SIZE = enum.auto()
18
+ DATA = enum.auto()
19
+ DATA_CRLF = enum.auto()
20
+ TRAILERS = enum.auto()
21
+ DONE = enum.auto()
22
+
23
+
24
+ class Decoder:
25
+ """Feed chunked bytes in any split and collect the decoded body pieces.
26
+
27
+ Size lines, chunk extensions and trailer fields are checked against the RFC 9112
28
+ grammar; only CRLF ends a line. Extensions are validated and then ignored, trailer
29
+ fields are kept in :attr:`trailers` (keep them apart from the header section, RFC 9110
30
+ section 6.5.2). Bytes after the final CRLF land in :attr:`unused_data`. When
31
+ :meth:`feed` raises, the pieces decoded in that call are dropped, and every later
32
+ :meth:`feed` raises again.
33
+ """
34
+
35
+ def __init__(self, limits: Limits | None = None) -> None:
36
+ self._limits = Limits() if limits is None else limits
37
+ self._buffer = bytearray()
38
+ self._scanned = 0
39
+ self._state = _State.SIZE
40
+ self._remaining = 0
41
+ self._body_size = 0
42
+ self._extensions_size = 0
43
+ self._trailer_size = 0
44
+ self._trailers: list[tuple[str, str]] = []
45
+ self._error: ChunkedError | None = None
46
+
47
+ @property
48
+ def is_complete(self) -> bool:
49
+ """Whether the last chunk and the trailer section have been fully consumed."""
50
+ return self._state is _State.DONE
51
+
52
+ @property
53
+ def trailers(self) -> list[tuple[str, str]]:
54
+ """Trailer fields received so far as ``(name, value)`` pairs, in arrival order."""
55
+ return list(self._trailers)
56
+
57
+ @property
58
+ def unused_data(self) -> bytes:
59
+ """Bytes received after the end of the chunked message (for example the next request)."""
60
+ return bytes(self._buffer) if self._state is _State.DONE else b""
61
+
62
+ def feed(self, data: bytes | bytearray | memoryview) -> list[bytes]:
63
+ """Append ``data`` and return every decoded body piece now available."""
64
+ if self._error is not None:
65
+ raise ChunkedError(f"the decoder already failed: {self._error}")
66
+ self._buffer += data
67
+ out: list[bytes] = []
68
+ try:
69
+ while self._step(out):
70
+ pass
71
+ except ChunkedError as error:
72
+ self._error = error
73
+ raise
74
+ return out
75
+
76
+ def _step(self, out: list[bytes]) -> bool:
77
+ if self._state is _State.SIZE:
78
+ return self._read_size()
79
+ if self._state is _State.DATA:
80
+ return self._read_data(out)
81
+ if self._state is _State.DATA_CRLF:
82
+ return self._read_data_crlf()
83
+ if self._state is _State.TRAILERS:
84
+ return self._read_trailer()
85
+ return False
86
+
87
+ def _read_size(self) -> bool:
88
+ line = self._take_line(self._limits.max_line_size, "chunk-size line")
89
+ if line is None:
90
+ return False
91
+ match = SIZE_LINE.fullmatch(line)
92
+ if match is None:
93
+ raise ChunkedError(f"malformed chunk-size line: {line[:64]!r}")
94
+ digits, extensions = match.group(1, 2)
95
+ self._extensions_size += len(extensions)
96
+ if self._extensions_size > self._limits.max_extensions_size:
97
+ raise LimitError("chunk extensions exceed max_extensions_size")
98
+ if len(digits.lstrip(b"0")) > _MAX_SIZE_DIGITS:
99
+ raise LimitError("chunk size exceeds max_chunk_size")
100
+ size = int(digits, 16)
101
+ if size > self._limits.max_chunk_size:
102
+ raise LimitError("chunk size exceeds max_chunk_size")
103
+ cap = self._limits.max_body_size
104
+ if cap is not None and self._body_size + size > cap:
105
+ raise LimitError("body exceeds max_body_size")
106
+ self._remaining = size
107
+ self._state = _State.DATA if size else _State.TRAILERS
108
+ return True
109
+
110
+ def _read_data(self, out: list[bytes]) -> bool:
111
+ if not self._buffer:
112
+ return False
113
+ take = min(self._remaining, len(self._buffer))
114
+ out.append(bytes(self._buffer[:take]))
115
+ del self._buffer[:take]
116
+ self._remaining -= take
117
+ self._body_size += take
118
+ if self._remaining == 0:
119
+ self._state = _State.DATA_CRLF
120
+ return True
121
+
122
+ def _read_data_crlf(self) -> bool:
123
+ # Fail on the first wrong byte: any two bytes here is the h11 smuggling bug.
124
+ head = bytes(self._buffer[:2])
125
+ if not b"\r\n".startswith(head):
126
+ raise ChunkedError("chunk data not followed by CRLF")
127
+ if len(head) < 2:
128
+ return False
129
+ del self._buffer[:2]
130
+ self._state = _State.SIZE
131
+ return True
132
+
133
+ def _read_trailer(self) -> bool:
134
+ budget = max(self._limits.max_trailer_size - self._trailer_size - 2, 0)
135
+ line = self._take_line(budget, "trailer section")
136
+ if line is None:
137
+ return False
138
+ if not line:
139
+ self._state = _State.DONE
140
+ return False
141
+ self._trailer_size += len(line) + 2
142
+ self._trailers.append(parse_field_line(line))
143
+ return True
144
+
145
+ def _take_line(self, limit: int, what: str) -> bytes | None:
146
+ """Cut one CRLF-terminated line off the buffer, scanning each byte only once."""
147
+ end = self._buffer.find(b"\n", self._scanned)
148
+ if end == -1:
149
+ self._scanned = len(self._buffer)
150
+ if self._scanned > limit + 1:
151
+ raise LimitError(f"{what} exceeds its limit")
152
+ return None
153
+ self._scanned = 0
154
+ if end == 0 or self._buffer[end - 1] != _CR:
155
+ raise ChunkedError(f"bare LF in the {what}")
156
+ if end - 1 > limit:
157
+ raise LimitError(f"{what} exceeds its limit")
158
+ line = bytes(self._buffer[: end - 1])
159
+ del self._buffer[: end + 1]
160
+ if _CR in line:
161
+ raise ChunkedError(f"bare CR in the {what}")
162
+ return line
163
+
164
+
165
+ def decode(data: bytes | bytearray | memoryview, *, limits: Limits | None = None) -> bytes:
166
+ """Decode one complete chunked message and return the body.
167
+
168
+ Raises :class:`ChunkedError` if the message is malformed, not terminated, or followed
169
+ by more bytes.
170
+ """
171
+ decoder = Decoder(limits)
172
+ pieces = decoder.feed(data)
173
+ if not decoder.is_complete:
174
+ raise ChunkedError("incomplete chunked message")
175
+ if decoder.unused_data:
176
+ raise ChunkedError("bytes after the end of the chunked message")
177
+ return b"".join(pieces)
chunkedte/_encode.py ADDED
@@ -0,0 +1,82 @@
1
+ """Encode bytes as an HTTP/1.1 chunked body, in one call or incrementally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+
7
+ from ._errors import ChunkedError
8
+ from ._grammar import format_field_line
9
+
10
+ DEFAULT_CHUNK_SIZE = 8192
11
+
12
+ Trailers = Mapping[str, str] | Iterable[tuple[str, str]]
13
+
14
+
15
+ class Encoder:
16
+ """Frame body pieces as chunks as they are produced, then close the message.
17
+
18
+ :meth:`write` turns one piece into one chunk (an empty piece yields nothing, since a
19
+ zero-size chunk would end the message); :meth:`finish` writes the last chunk and the
20
+ optional trailer section. Trailer names must be tokens and values field-values, and
21
+ fields that frame or route a message (Content-Length, Transfer-Encoding, Host, ...)
22
+ are refused as trailers.
23
+ """
24
+
25
+ def __init__(self) -> None:
26
+ self._finished = False
27
+
28
+ def write(self, data: bytes | bytearray | memoryview) -> bytes:
29
+ """Return ``data`` framed as one chunk."""
30
+ if self._finished:
31
+ raise ChunkedError("write after finish")
32
+ piece = _as_bytes(data)
33
+ if not piece:
34
+ return b""
35
+ return b"%x\r\n%b\r\n" % (len(piece), piece)
36
+
37
+ def finish(self, trailers: Trailers | None = None) -> bytes:
38
+ """Return the last chunk, the trailer section and the closing CRLF."""
39
+ if self._finished:
40
+ raise ChunkedError("finish called twice")
41
+ lines = [format_field_line(name, value) + b"\r\n" for name, value in _pairs(trailers)]
42
+ self._finished = True
43
+ return b"0\r\n" + b"".join(lines) + b"\r\n"
44
+
45
+
46
+ def encode(
47
+ data: bytes | bytearray | memoryview,
48
+ *,
49
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
50
+ trailers: Trailers | None = None,
51
+ ) -> bytes:
52
+ """Frame ``data`` as a complete chunked body split into ``chunk_size`` pieces.
53
+
54
+ The result is ``<hex-size>CRLF<data>CRLF`` chunks, then ``0CRLF``, the trailer fields
55
+ and the closing CRLF. An empty ``data`` yields just the last chunk.
56
+ """
57
+ if not isinstance(chunk_size, int) or isinstance(chunk_size, bool):
58
+ raise TypeError("chunk_size must be an int")
59
+ if chunk_size <= 0:
60
+ raise ValueError("chunk_size must be positive")
61
+ body = _as_bytes(data)
62
+ encoder = Encoder()
63
+ parts = [encoder.write(body[at : at + chunk_size]) for at in range(0, len(body), chunk_size)]
64
+ parts.append(encoder.finish(trailers))
65
+ return b"".join(parts)
66
+
67
+
68
+ def _as_bytes(data: bytes | bytearray | memoryview) -> bytes:
69
+ # bytes(5) would silently build five NUL bytes.
70
+ if not isinstance(data, (bytes, bytearray, memoryview)):
71
+ raise TypeError(f"expected a bytes-like object, got {type(data).__name__}")
72
+ return bytes(data)
73
+
74
+
75
+ def _pairs(trailers: Trailers | None) -> list[tuple[str, str]]:
76
+ if trailers is None:
77
+ return []
78
+ if isinstance(trailers, (str, bytes)):
79
+ raise TypeError("trailers must be a mapping or (name, value) pairs")
80
+ if isinstance(trailers, Mapping):
81
+ return list(trailers.items())
82
+ return [(name, value) for name, value in trailers]
chunkedte/_errors.py ADDED
@@ -0,0 +1,11 @@
1
+ """Errors raised for malformed or oversized chunked messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ChunkedError(ValueError):
7
+ """A chunked message or a framing field breaks RFC 9112 (answer with 400)."""
8
+
9
+
10
+ class LimitError(ChunkedError):
11
+ """A configured :class:`Limits` bound was passed (answer with 413 or 431)."""
chunkedte/_framing.py ADDED
@@ -0,0 +1,66 @@
1
+ """Decide from Transfer-Encoding and Content-Length whether a body is chunked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ._errors import ChunkedError
8
+
9
+ _TOKEN = r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+"
10
+ _QUOTED = r'"(?:[\t \x21\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\t \x21-\x7e\x80-\xff])*"'
11
+ # RFC 9110 section 10.1.4: transfer-coding = token *( OWS ";" OWS transfer-parameter ).
12
+ _CODING = re.compile(rf"({_TOKEN})((?:[ \t]*;[ \t]*{_TOKEN}[ \t]*=[ \t]*(?:{_TOKEN}|{_QUOTED}))*)")
13
+ # The trailing OWS sits inside the optional group: two adjacent OWS runs backtrack
14
+ # quadratically on a long run of spaces.
15
+ _MEMBER = re.compile(rf"[ \t]*(?:{_CODING.pattern}[ \t]*)?(?:,|\Z)")
16
+
17
+
18
+ def is_chunked(
19
+ transfer_encoding: str | None,
20
+ content_length: str | None = None,
21
+ *,
22
+ request: bool = True,
23
+ ) -> bool:
24
+ """Whether a message body is framed by the chunked coding (RFC 9112 sections 6.1, 6.3).
25
+
26
+ Pass the field values as received, joining repeated lines with ``", "``, or ``None``
27
+ when a field is absent. Returns ``False`` when there is no Transfer-Encoding, and for a
28
+ response whose final coding is not chunked (it is read until the connection closes).
29
+ Raises :class:`ChunkedError` when both fields are present, when chunked is applied
30
+ twice or carries parameters, when a request's final coding is not chunked, and when the
31
+ field is not a valid list of transfer codings.
32
+
33
+ The caller still applies the rest of RFC 9112 section 6.3: responses to HEAD, 1xx, 204,
34
+ 304 and 2xx to CONNECT have no body, and an HTTP/1.0 message with Transfer-Encoding has
35
+ faulty framing (section 6.1).
36
+ """
37
+ if transfer_encoding is None:
38
+ return False
39
+ if content_length is not None:
40
+ raise ChunkedError("both Transfer-Encoding and Content-Length are present")
41
+ codings = _parse_codings(transfer_encoding)
42
+ if not codings:
43
+ raise ChunkedError("Transfer-Encoding lists no transfer coding")
44
+ chunked = [index for index, (name, _) in enumerate(codings) if name == "chunked"]
45
+ if len(chunked) > 1:
46
+ raise ChunkedError("chunked is applied more than once")
47
+ if chunked and codings[chunked[0]][1]:
48
+ raise ChunkedError("the chunked coding does not take parameters")
49
+ if chunked and chunked[0] == len(codings) - 1:
50
+ return True
51
+ if request:
52
+ raise ChunkedError("chunked is not the final transfer coding of a request")
53
+ return False
54
+
55
+
56
+ def _parse_codings(value: str) -> list[tuple[str, str]]:
57
+ codings: list[tuple[str, str]] = []
58
+ position = 0
59
+ while position < len(value):
60
+ match = _MEMBER.match(value, position)
61
+ if match is None or match.end() == position:
62
+ raise ChunkedError(f"malformed Transfer-Encoding: {value[:64]!r}")
63
+ if match.group(1) is not None:
64
+ codings.append((match.group(1).lower(), match.group(2)))
65
+ position = match.end()
66
+ return codings
chunkedte/_grammar.py ADDED
@@ -0,0 +1,85 @@
1
+ """RFC 9112 and RFC 9110 grammar for size lines and field lines, as byte regexes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ._errors import ChunkedError
8
+
9
+ # RFC 9110 section 5.6.2: tchar is any VCHAR except the delimiters.
10
+ TCHAR = rb"!#$%&'*+\-.^_`|~0-9A-Za-z"
11
+ TOKEN = rb"[" + TCHAR + rb"]+"
12
+ # RFC 9110 section 5.6.4: qdtext and quoted-pair; obs-text is %x80-FF.
13
+ QDTEXT = rb"[\t \x21\x23-\x5b\x5d-\x7e\x80-\xff]"
14
+ QUOTED_PAIR = rb"\\[\t \x21-\x7e\x80-\xff]"
15
+ QUOTED_STRING = rb'"(?:' + QDTEXT + rb"|" + QUOTED_PAIR + rb')*"'
16
+ BWS = rb"[ \t]*"
17
+ # RFC 9112 section 7.1.1: chunk-ext = *( BWS ";" BWS name [ BWS "=" BWS value ] ).
18
+ CHUNK_EXT = (
19
+ rb"(?:" + BWS + rb";" + BWS + TOKEN
20
+ + rb"(?:" + BWS + rb"=" + BWS + rb"(?:" + TOKEN + rb"|" + QUOTED_STRING + rb"))?)*"
21
+ ) # fmt: skip
22
+ SIZE_LINE = re.compile(rb"([0-9A-Fa-f]+)(" + CHUNK_EXT + rb")")
23
+ FIELD_NAME = re.compile(TOKEN + rb":")
24
+ # RFC 9110 section 5.5: field-vchar, SP and HTAB; the ends are checked by stripping OWS.
25
+ FIELD_VALUE = re.compile(rb"[\t \x21-\x7e\x80-\xff]*")
26
+ TOKEN_RE = re.compile(TOKEN)
27
+
28
+ # Fields needed before the content (framing, routing, connection control, authentication,
29
+ # request modifiers, response controls, content format: RFC 9110 section 6.5.1) cannot be
30
+ # sent as trailers, so the encoder refuses them.
31
+ FORBIDDEN_TRAILERS = frozenset(
32
+ {
33
+ "authorization",
34
+ "cache-control",
35
+ "connection",
36
+ "content-encoding",
37
+ "content-length",
38
+ "content-range",
39
+ "content-type",
40
+ "cookie",
41
+ "expect",
42
+ "host",
43
+ "keep-alive",
44
+ "max-forwards",
45
+ "proxy-authenticate",
46
+ "proxy-authorization",
47
+ "range",
48
+ "set-cookie",
49
+ "te",
50
+ "trailer",
51
+ "transfer-encoding",
52
+ "upgrade",
53
+ "www-authenticate",
54
+ }
55
+ )
56
+
57
+
58
+ def parse_field_line(line: bytes) -> tuple[str, str]:
59
+ """Split one trailer field line into its name and its OWS-trimmed value."""
60
+ if line[:1] in (b" ", b"\t"):
61
+ raise ChunkedError("obsolete line folding in the trailer section")
62
+ match = FIELD_NAME.match(line)
63
+ if match is None:
64
+ raise ChunkedError(f"malformed trailer field line: {line[:64]!r}")
65
+ value = line[match.end() :].strip(b" \t")
66
+ if not FIELD_VALUE.fullmatch(value):
67
+ raise ChunkedError(f"invalid byte in trailer field value: {line[:64]!r}")
68
+ return line[: match.end() - 1].decode("ascii"), value.decode("latin-1")
69
+
70
+
71
+ def format_field_line(name: str, value: str) -> bytes:
72
+ """Validate one trailer field for sending and return ``name: value`` as bytes."""
73
+ if not isinstance(name, str) or not isinstance(value, str):
74
+ raise TypeError("trailer names and values must be str")
75
+ try:
76
+ raw_name, raw_value = name.encode("ascii"), value.encode("latin-1")
77
+ except UnicodeEncodeError:
78
+ raise ChunkedError(f"trailer field {name!r} is not latin-1 text") from None
79
+ if not TOKEN_RE.fullmatch(raw_name):
80
+ raise ChunkedError(f"trailer name is not a token: {name!r}")
81
+ if name.lower() in FORBIDDEN_TRAILERS:
82
+ raise ChunkedError(f"{name} cannot be sent as a trailer field")
83
+ if not FIELD_VALUE.fullmatch(raw_value) or raw_value != raw_value.strip(b" \t"):
84
+ raise ChunkedError(f"invalid trailer value for {name}: {value!r}")
85
+ return raw_name + b": " + raw_value
chunkedte/_limits.py ADDED
@@ -0,0 +1,33 @@
1
+ """Bounds that keep a decoder's memory and work proportional to what the caller allows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, fields
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Limits:
10
+ """Upper bounds applied while decoding; passing one raises :class:`LimitError`.
11
+
12
+ ``max_line_size`` bounds one chunk-size line (size plus extensions, CRLF excluded),
13
+ ``max_extensions_size`` the extension bytes of the whole message, ``max_chunk_size`` the
14
+ value of one chunk-size, ``max_body_size`` the decoded body (``None`` for no bound) and
15
+ ``max_trailer_size`` the trailer field lines including their CRLFs.
16
+ """
17
+
18
+ max_line_size: int = 4096
19
+ max_extensions_size: int = 16384
20
+ # Below 2**63 so a forwarded size never wraps in a downstream signed 64-bit parser.
21
+ max_chunk_size: int = 2**63 - 1
22
+ max_body_size: int | None = None
23
+ max_trailer_size: int = 16384
24
+
25
+ def __post_init__(self) -> None:
26
+ for field in fields(self):
27
+ value = getattr(self, field.name)
28
+ if value is None and field.name == "max_body_size":
29
+ continue
30
+ if not isinstance(value, int) or isinstance(value, bool):
31
+ raise TypeError(f"{field.name} must be an int, got {type(value).__name__}")
32
+ if value < 0:
33
+ raise ValueError(f"{field.name} must not be negative")
chunkedte/py.typed ADDED
File without changes
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.5
2
+ Name: chunkedte
3
+ Version: 0.2.0
4
+ Summary: Strict, incremental, typed, zero-dependency codec for HTTP/1.1 chunked transfer coding
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/chunkedte
7
+ Project-URL: Issues, https://github.com/JaviLianes8/chunkedte/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/chunkedte/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: chunked,codec,http,request-smuggling,rfc9112,trailers,transfer-encoding
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Provides-Extra: dev
30
+ Requires-Dist: build; extra == 'dev'
31
+ Requires-Dist: mypy; extra == 'dev'
32
+ Requires-Dist: pytest; extra == 'dev'
33
+ Requires-Dist: ruff; extra == 'dev'
34
+ Requires-Dist: twine; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # chunkedte
38
+
39
+ Strict, incremental, typed, zero-dependency codec for HTTP/1.1 chunked transfer coding
40
+ (RFC 9112 section 7.1).
41
+
42
+ ## Install
43
+
44
+ ```
45
+ pip install chunkedte
46
+ ```
47
+
48
+ Python 3.10 or newer. No dependencies.
49
+
50
+ ## Usage
51
+
52
+ Decode a complete message in one call. `decode` raises `ChunkedError` if the message is
53
+ malformed, unfinished, or followed by more bytes:
54
+
55
+ ```python
56
+ from chunkedte import ChunkedError, decode
57
+
58
+ print(decode(b"5\r\nhello\r\n0\r\n\r\n"))
59
+ try:
60
+ decode(b"0x5\r\nhello\r\n0\r\n\r\n")
61
+ except ChunkedError as error:
62
+ print(error)
63
+ # b'hello'
64
+ # malformed chunk-size line: b'0x5'
65
+ ```
66
+
67
+ Decode incrementally as bytes arrive off a socket. `Decoder.feed` takes any split and
68
+ returns the body pieces available so far; trailer fields and whatever follows the message
69
+ (a pipelined request, say) are kept apart:
70
+
71
+ ```python
72
+ from chunkedte import Decoder
73
+
74
+ decoder = Decoder()
75
+ body = b""
76
+ for part in [b"4\r\nwi", b"ki\r\n5;sig=ab\r\npedia\r\n0\r\nDigest: x\r\n", b"\r\nGET /"]:
77
+ for piece in decoder.feed(part):
78
+ body += piece
79
+ print(body, decoder.is_complete)
80
+ print(decoder.trailers, decoder.unused_data)
81
+ # b'wikipedia' True
82
+ # [('Digest', 'x')] b'GET /'
83
+ ```
84
+
85
+ Bound what a peer can make you buffer. Passing a bound raises `LimitError`, a subclass of
86
+ `ChunkedError`, so a server can answer 413 or 431 instead of 400:
87
+
88
+ ```python
89
+ from chunkedte import Decoder, LimitError, Limits
90
+
91
+ decoder = Decoder(Limits(max_body_size=1024, max_trailer_size=4096))
92
+ try:
93
+ decoder.feed(b"10000\r\n")
94
+ except LimitError as error:
95
+ print(error)
96
+ print(Limits())
97
+ # body exceeds max_body_size
98
+ # Limits(max_line_size=4096, max_extensions_size=16384, max_chunk_size=9223372036854775807, max_body_size=None, max_trailer_size=16384)
99
+ ```
100
+
101
+ Encode in one call, or stream with `Encoder` and close with optional trailer fields:
102
+
103
+ ```python
104
+ from chunkedte import Encoder, encode
105
+
106
+ print(encode(b"abcd", chunk_size=2))
107
+ encoder = Encoder()
108
+ print(encoder.write(b"hello") + encoder.finish({"X-Checksum": "42"}))
109
+ # b'2\r\nab\r\n2\r\ncd\r\n0\r\n\r\n'
110
+ # b'5\r\nhello\r\n0\r\nX-Checksum: 42\r\n\r\n'
111
+ ```
112
+
113
+ Decide whether a message body is chunked at all from its Transfer-Encoding and
114
+ Content-Length values (`None` when absent). `is_chunked` applies RFC 9112 sections 6.1 and
115
+ 6.3 and raises `ChunkedError` on the combinations used for request smuggling:
116
+
117
+ ```python
118
+ from chunkedte import ChunkedError, is_chunked
119
+
120
+ print(is_chunked("gzip, chunked"), is_chunked(None, "12"))
121
+ print(is_chunked("gzip", request=False))
122
+ for te, cl in [("chunked", "12"), ("chunked, chunked", None), ("chunked, gzip", None)]:
123
+ try:
124
+ is_chunked(te, cl)
125
+ except ChunkedError as error:
126
+ print(error)
127
+ # True False
128
+ # False
129
+ # both Transfer-Encoding and Content-Length are present
130
+ # chunked is applied more than once
131
+ # chunked is not the final transfer coding of a request
132
+ ```
133
+
134
+ ### Command line
135
+
136
+ ```
137
+ $ printf '5\r\nhello\r\n0\r\nX-Sum: 1\r\n\r\n' | chunkedte decode
138
+ hello
139
+ X-Sum: 1
140
+ $ printf 'abc' | chunkedte encode --chunk-size 2 --trailer "X-Sum: 1"
141
+ ```
142
+
143
+ `decode` writes the body to stdout and the trailer fields to stderr; `encode` frames stdin
144
+ (or a file) as a chunked body. Both exit with status 1 and a message on bad input; body
145
+ bytes decoded before the error may already be on stdout, so treat the output of a failed
146
+ run as truncated. Trailer bytes outside printable ASCII are written as escapes.
147
+ `python -m chunkedte` works too.
148
+
149
+ ## What "strict" means here
150
+
151
+ Every leniency in a chunked parser is a way for two HTTP processors to disagree on where a
152
+ message ends, which is how request smuggling works. So the decoder rejects:
153
+
154
+ - a chunk size that is not only hex digits (`+5`, `0x5`, `5_0`, ` 5`, `5 `);
155
+ - chunk extensions that do not follow the grammar (whitespace is allowed only around `;`
156
+ and `=`, values are tokens or quoted strings);
157
+ - a bare LF or a bare CR anywhere a CRLF is expected, including inside an extension;
158
+ - anything other than CRLF right after the chunk data;
159
+ - trailer lines that are not `name: value`, with whitespace before the colon, with control
160
+ bytes, or folded onto a continuation line (obs-fold).
161
+
162
+ Extensions are validated and then ignored, as RFC 9112 asks. The default `Limits` cap one
163
+ size line at 4 KiB, all extensions of a message at 16 KiB, the trailer section at 16 KiB and
164
+ a chunk size below 2**63 (so a forwarded size never wraps in a 64-bit parser); the body is
165
+ unbounded unless you set `max_body_size`. A line split over many small reads is scanned
166
+ once, so decoding stays linear in the input. When `feed` raises, the pieces decoded in that
167
+ call are dropped with it. Received trailers are returned as they came: RFC 9110 section
168
+ 6.5.2 says not to merge them into the header section, so keep them apart (a `Content-Length`
169
+ trailer, say, must never reach your framing logic). The encoder refuses trailer names that
170
+ are not tokens, values with control bytes or edge whitespace, and the fields RFC 9110
171
+ section 6.5.1 needs before the content: framing and routing (Content-Length,
172
+ Transfer-Encoding, Trailer, TE, Host, Connection, Keep-Alive, Upgrade, Max-Forwards),
173
+ authentication (Authorization, Proxy-Authorization, WWW-Authenticate, Proxy-Authenticate,
174
+ Cookie, Set-Cookie), request modifiers and response controls (Expect, Range,
175
+ Cache-Control) and content format (Content-Type, Content-Encoding, Content-Range).
176
+ `is_chunked` leaves the rest of RFC 9112 section 6.3 to you: responses to HEAD, 1xx, 204,
177
+ 304 and 2xx to CONNECT carry no body, and an HTTP/1.0 message with Transfer-Encoding has
178
+ faulty framing. There is no lenient mode.
179
+
180
+ ## Why
181
+
182
+ Chunked transfer coding is simple to frame and easy to get subtly wrong: the terminating
183
+ `0` chunk, the closing blank line, extensions and trailers are routinely mishandled, and
184
+ the security advisories of the last few years (waitress, h11, aiohttp, netty, Go net/http,
185
+ Node.js) are all variations on a parser accepting bytes another parser reads differently.
186
+ The existing Python options are tied to one HTTP stack (`h11`, `aiohttp`) or to one server.
187
+ This is a standalone, typed, zero-dependency codec with the strict behaviour by default:
188
+ reach for it when you move chunked framing yourself and do not want a whole HTTP stack.
189
+
190
+ ## License
191
+
192
+ MIT. See [LICENSE](LICENSE).
193
+
194
+ ## Links
195
+
196
+ - Website: https://lianes8studio.com
197
+ - GitHub: https://github.com/JaviLianes8
198
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,15 @@
1
+ chunkedte/__init__.py,sha256=eq8oeU3qOWLxiwajxdas9IHaF8Gs4Q-Uw9aM6THAoJA,478
2
+ chunkedte/__main__.py,sha256=bkGcWn079Lh8m4EhrjcooxJWrj2JDycl_NG9cYSbEPA,127
3
+ chunkedte/_cli.py,sha256=1BDahA4XtXMTS81S1XJH1wFdBxdu1qD_cMVTo4TcxqA,3364
4
+ chunkedte/_decode.py,sha256=H2OBxW_6x1Ya-cgzzcoXq1YGz7XD8tXEtO8zTspt02Y,6723
5
+ chunkedte/_encode.py,sha256=WuXHQF6Ck-8wGqjUjOixsk_SOpkTOp3PAVDjBUa_It0,3085
6
+ chunkedte/_errors.py,sha256=AwFBoRQ3p7Qe9_YdFolEDbuidgFIWOfRpj25UBWqLGI,333
7
+ chunkedte/_framing.py,sha256=HPq26FW0eIquFKTrZZ47GIig0rtnnYTEWoXPnYlKEFk,2907
8
+ chunkedte/_grammar.py,sha256=fWOOu-wJ49T7K7U6UnrZ2Q8fGa4yhTHmFGzZOkBWQY8,3345
9
+ chunkedte/_limits.py,sha256=jxYokXDOP4adzn978UUwRuiQsxrZy2fYlrRNOMy5N9w,1378
10
+ chunkedte/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ chunkedte-0.2.0.dist-info/METADATA,sha256=d-IglDkw9nW13VzBmgR85ddTWur5wkfR14P15LDdKSI,7987
12
+ chunkedte-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ chunkedte-0.2.0.dist-info/entry_points.txt,sha256=gqJM4lraYEUzxKaMMTXz1kPb0vHJXXvZWp9LYKgTo80,50
14
+ chunkedte-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
15
+ chunkedte-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chunkedte = chunkedte._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.