spillgate 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.
spillgate/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """spillgate: decode untrusted compressed data under mandatory limits.
2
+
3
+ A decompression bomb is a small input that expands to an enormous output. spillgate
4
+ stops it at the gate: every decoding layer has a hard output limit, an expansion
5
+ ratio limit and a bounded chain length, and anything that exceeds a limit ends in
6
+ a typed error, never in silent truncation.
7
+
8
+ >>> from spillgate import Limits, decode_all
9
+ >>> import gzip
10
+ >>> decode_all(gzip.compress(b"hello"), "gzip", Limits(max_output=1 << 20))
11
+ b'hello'
12
+
13
+ The normative rules are in ``spec/SPEC.md``; rule IDs such as ``SG-R-LAY-03`` in
14
+ comments and docstrings point there. Design principle: when in doubt, refuse.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .codec import Codec, CodecReader, Source
20
+ from .decoder import Decoder, decode_all, reader
21
+ from .errors import (
22
+ CanceledError,
23
+ ClosedError,
24
+ CorruptError,
25
+ InputLimitError,
26
+ InternalError,
27
+ InvalidLimitsError,
28
+ InvalidOptionError,
29
+ Kind,
30
+ LayerLimitError,
31
+ MalformedEncodingError,
32
+ MemoryLimitError,
33
+ OutputLimitError,
34
+ RatioLimitError,
35
+ SourceError,
36
+ SpillgateError,
37
+ UnsupportedEncodingError,
38
+ )
39
+ from .limits import (
40
+ DEFAULT_MAX_LAYERS,
41
+ DEFAULT_MAX_RATIO,
42
+ DEFAULT_RATIO_GRACE,
43
+ HARD_MAX_LAYERS,
44
+ HARD_MAX_RATIO,
45
+ Limits,
46
+ )
47
+
48
+ __all__ = [
49
+ "DEFAULT_MAX_LAYERS",
50
+ "DEFAULT_MAX_RATIO",
51
+ "DEFAULT_RATIO_GRACE",
52
+ "HARD_MAX_LAYERS",
53
+ "HARD_MAX_RATIO",
54
+ "CanceledError",
55
+ "ClosedError",
56
+ "Codec",
57
+ "CodecReader",
58
+ "CorruptError",
59
+ "Decoder",
60
+ "InputLimitError",
61
+ "InternalError",
62
+ "InvalidLimitsError",
63
+ "InvalidOptionError",
64
+ "Kind",
65
+ "LayerLimitError",
66
+ "Limits",
67
+ "MalformedEncodingError",
68
+ "MemoryLimitError",
69
+ "OutputLimitError",
70
+ "RatioLimitError",
71
+ "Source",
72
+ "SourceError",
73
+ "SpillgateError",
74
+ "UnsupportedEncodingError",
75
+ "decode_all",
76
+ "reader",
77
+ ]
spillgate/_cancel.py ADDED
@@ -0,0 +1,25 @@
1
+ """Polling the caller's ``cancel`` callable, with its failures contained."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from .errors import CanceledError, InternalError, SpillgateError
8
+
9
+
10
+ def poll(cancel: Callable[[], bool] | None, layer: int) -> SpillgateError | None:
11
+ """Return the error to make sticky if the caller wants to stop, else ``None``.
12
+
13
+ A callable that raises is a bug in the caller's code, not a reason to let a foreign
14
+ exception escape half-way through a read: it becomes a sticky ``internal`` error
15
+ (SG-I-05). ``KeyboardInterrupt`` and ``SystemExit`` are not ``Exception`` and pass.
16
+ """
17
+ if cancel is None:
18
+ return None
19
+ try:
20
+ wanted = bool(cancel())
21
+ except Exception as error:
22
+ failure = InternalError(f"the cancel callable raised {type(error).__name__}", layer=layer)
23
+ failure.__cause__ = error
24
+ return failure
25
+ return CanceledError("canceled by the caller", layer=layer) if wanted else None
spillgate/_chain.py ADDED
@@ -0,0 +1,117 @@
1
+ """The reader returned to the caller: the last layer plus the end-to-end ratio check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ from typing import Any
8
+
9
+ from ._guard import MAX_STEP, LayerGuard
10
+ from ._meter import InputMeter
11
+ from .errors import ClosedError, RatioLimitError, SpillgateError, fresh
12
+ from .limits import EffectiveLimits
13
+
14
+ _END = object()
15
+
16
+
17
+ class ChainReader(io.RawIOBase):
18
+ """A binary stream of the decoded bytes. Use it like any other raw file object.
19
+
20
+ Reading can deliver bytes and then fail: treat any
21
+ :class:`~spillgate.SpillgateError` as failure of the whole body (SG-R-LAY-06).
22
+ Errors are sticky: every later read raises the same error (SG-R-LAY-05).
23
+ Closing does not close the caller's source. Not safe for concurrent use.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ top: LayerGuard,
29
+ source: InputMeter,
30
+ limits: EffectiveLimits,
31
+ codecs: list[Any],
32
+ ) -> None:
33
+ super().__init__()
34
+ self._top = top
35
+ self._source = source # the meter of layer 1: what was taken from the caller's source
36
+ self._limits = limits
37
+ self._codecs = codecs # codec readers that need releasing
38
+ self._produced = 0
39
+ self._done: object | SpillgateError | None = None
40
+
41
+ def readable(self) -> bool:
42
+ return True
43
+
44
+ def readinto(self, buffer: Any) -> int:
45
+ view = memoryview(buffer).cast("B")
46
+ if self.closed:
47
+ raise ClosedError("I/O operation on a closed spillgate reader")
48
+ if self._done is not None:
49
+ return self._replay()
50
+ if len(view) == 0:
51
+ return 0
52
+ try:
53
+ data = self._top.read(len(view))
54
+ except SpillgateError as error:
55
+ self._done = fresh(error)
56
+ raise
57
+ if not data:
58
+ self._done = _END
59
+ return 0
60
+ view[: len(data)] = data
61
+ self._produced += len(data)
62
+ # SG-R-RAT-03: the same ratio test, end to end. The error follows these bytes.
63
+ # A layer that already failed has the more specific error, so it wins.
64
+ if not self._top.failed and self._limits.ratio_exceeded(
65
+ self._produced, self._source.consumed
66
+ ):
67
+ self._done = RatioLimitError(
68
+ "end-to-end expansion over the whole chain", limit=self._limits.max_ratio
69
+ )
70
+ return len(data)
71
+
72
+ def read(self, size: int | None = -1) -> bytes:
73
+ """Read up to ``size`` bytes (at most one step); ``-1`` or ``None`` reads to the end.
74
+
75
+ The buffer is never larger than one step, whatever ``size`` is, so a huge
76
+ ``size`` does not allocate a huge buffer (SG-R-MEM-02).
77
+ """
78
+ if size is None or size < 0:
79
+ return self.readall()
80
+ buffer = bytearray(min(size, MAX_STEP))
81
+ count = self.readinto(buffer)
82
+ return bytes(buffer[:count])
83
+
84
+ def readall(self) -> bytes:
85
+ """Read to the end. Memory grows with the bytes produced, never with a limit."""
86
+ out = bytearray()
87
+ while True:
88
+ chunk = self.read(MAX_STEP)
89
+ if not chunk:
90
+ return bytes(out)
91
+ out += chunk
92
+
93
+ def _replay(self) -> int:
94
+ if self._done is _END:
95
+ return 0
96
+ assert isinstance(self._done, SpillgateError)
97
+ raise fresh(self._done)
98
+
99
+ def close(self) -> None:
100
+ """Release codec state. Idempotent; the caller's source is left open (SG-R-RDR-06)."""
101
+ if self.closed:
102
+ return
103
+ codecs, self._codecs = self._codecs, []
104
+ try:
105
+ for codec in codecs:
106
+ _close_contained(codec)
107
+ finally:
108
+ super().close()
109
+
110
+
111
+ def _close_contained(codec: Any) -> None:
112
+ """Close a codec reader; a failing ``close`` must not stop the others (SG-R-CDC-08)."""
113
+ close = getattr(codec, "close", None)
114
+ if close is None:
115
+ return
116
+ with contextlib.suppress(Exception): # contained on purpose
117
+ close()
spillgate/_deflate.py ADDED
@@ -0,0 +1,89 @@
1
+ """HTTP ``deflate`` (RFC 9110 section 8.4.1.2), SG-R-CDC-03.
2
+
3
+ ``deflate`` in HTTP means the zlib format (RFC 1950 around RFC 1951 data), but some
4
+ senders omit the wrapper. The first two bytes decide: a valid zlib header means zlib,
5
+ anything else is raw DEFLATE, unless ``strict_zlib`` forbids the fallback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import zlib
11
+
12
+ from ._inflate import RawInflater
13
+ from ._meter import InputMeter
14
+ from .errors import CorruptError
15
+
16
+ _CM_DEFLATE = 8
17
+ _MAX_CINFO = 7
18
+ _FLAG_DICT = 1 << 5
19
+ _CHECK_MODULUS = 31
20
+ _TRAILER = 4 # Adler-32, big-endian
21
+
22
+
23
+ def is_zlib_header(cmf: int, flg: int) -> bool:
24
+ """RFC 1950: CM = 8, CINFO <= 7 and (CMF * 256 + FLG) divisible by 31."""
25
+ return (
26
+ cmf & 0x0F == _CM_DEFLATE
27
+ and cmf >> 4 <= _MAX_CINFO
28
+ and (cmf << 8 | flg) % _CHECK_MODULUS == 0
29
+ )
30
+
31
+
32
+ class DeflateCodec:
33
+ """The built-in ``deflate`` codec."""
34
+
35
+ def __init__(self, *, strict_zlib: bool = False) -> None:
36
+ self._strict = strict_zlib
37
+
38
+ def new_reader(self, source: InputMeter) -> DeflateReader:
39
+ return DeflateReader(source, strict=self._strict)
40
+
41
+
42
+ class DeflateReader:
43
+ """Decodes one zlib or raw DEFLATE stream and checks that nothing follows it."""
44
+
45
+ def __init__(self, source: InputMeter, *, strict: bool) -> None:
46
+ self._source = source
47
+ self._strict = strict
48
+ self._body: RawInflater | None = None
49
+ self._adler: int | None = None # None for raw DEFLATE
50
+ self._ended = False
51
+
52
+ def read(self, n: int) -> bytes:
53
+ if self._ended:
54
+ return b""
55
+ if self._body is None:
56
+ self._start()
57
+ assert self._body is not None
58
+ data = self._body.read(n)
59
+ if data:
60
+ if self._adler is not None:
61
+ self._adler = zlib.adler32(data, self._adler)
62
+ return data
63
+ self._finish()
64
+ self._ended = True
65
+ return b""
66
+
67
+ def close(self) -> None:
68
+ self._body = None
69
+
70
+ def _start(self) -> None:
71
+ header = self._source.take_exact(2, "deflate header")
72
+ if is_zlib_header(header[0], header[1]):
73
+ if header[1] & _FLAG_DICT:
74
+ raise CorruptError("deflate: preset dictionaries are not supported")
75
+ self._adler = 1 # the Adler-32 of no data
76
+ elif self._strict:
77
+ raise CorruptError("deflate: not a zlib stream")
78
+ else:
79
+ self._source.unread(header) # raw DEFLATE: the two bytes are data
80
+ self._body = RawInflater(self._source, "deflate")
81
+
82
+ def _finish(self) -> None:
83
+ """The DEFLATE data ended: verify the Adler-32 and that nothing follows."""
84
+ if self._adler is not None:
85
+ trailer = self._source.take_exact(_TRAILER, "deflate trailer")
86
+ if int.from_bytes(trailer, "big") != self._adler:
87
+ raise CorruptError("deflate: checksum mismatch")
88
+ if not self._source.at_end():
89
+ raise CorruptError("deflate: unexpected data after the end of the stream")
spillgate/_guard.py ADDED
@@ -0,0 +1,154 @@
1
+ """The layer guard: everything the specification demands of one layer.
2
+
3
+ Whatever the codec does, the guard never hands out more than ``max_output`` bytes
4
+ in total (SG-I-01), never turns a limit hit into a clean end (SG-I-02), and keeps
5
+ raising the same terminal condition once one occurred (SG-I-03).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from ._cancel import poll
14
+ from ._meter import InputMeter
15
+ from .errors import (
16
+ InternalError,
17
+ OutputLimitError,
18
+ RatioLimitError,
19
+ SpillgateError,
20
+ fresh,
21
+ )
22
+ from .limits import EffectiveLimits
23
+
24
+ # The most a guard asks a codec for in one call. It bounds the memory of one step
25
+ # (SG-R-MEM-02) whatever size of buffer the caller reads with; callers get short
26
+ # reads, which every reader contract allows.
27
+ MAX_STEP = 64 * 1024
28
+
29
+ _END = object() # sticky "clean end" marker, as opposed to an error
30
+
31
+
32
+ class LayerGuard:
33
+ """Wraps the output of one decoding layer. Not safe for concurrent use."""
34
+
35
+ def __init__(
36
+ self,
37
+ codec: Any,
38
+ meter: InputMeter,
39
+ layer: int,
40
+ limits: EffectiveLimits,
41
+ cancel: Callable[[], bool] | None = None,
42
+ ) -> None:
43
+ self._codec = codec # has read(n) -> bytes
44
+ self._cancel = cancel
45
+ self._meter = meter
46
+ self._layer = layer
47
+ self._limits = limits
48
+ self._produced = 0
49
+ self._done: object | SpillgateError | None = None # None, _END or the error
50
+
51
+ @property
52
+ def failed(self) -> bool:
53
+ """True once the layer has a terminal error (a clean end is not a failure)."""
54
+ return isinstance(self._done, SpillgateError)
55
+
56
+ def read(self, n: int) -> bytes:
57
+ """Return up to ``n`` bytes; ``b""`` is the clean end.
58
+
59
+ Raises:
60
+ SpillgateError: the layer failed. The same error is raised on every
61
+ later call (SG-R-LAY-05).
62
+ """
63
+ if self._done is not None:
64
+ return self._replay()
65
+ if n <= 0:
66
+ return b"" # SG-R-RDR-02: a zero-length read changes nothing
67
+ stop = poll(self._cancel, self._layer)
68
+ if stop is not None:
69
+ self._done = stop
70
+ return self._replay()
71
+ remaining = self._limits.max_output - self._produced
72
+ if remaining <= 0:
73
+ return self._probe()
74
+ data = self._call_codec(min(n, remaining, MAX_STEP))
75
+ if not data:
76
+ self._done = _END
77
+ return b""
78
+ self._produced += len(data)
79
+ # The bytes that broke the ratio were produced first, so they are still
80
+ # delivered; the error follows on the next read (SG-R-LAY-07).
81
+ if self._limits.ratio_exceeded(self._produced, self._meter.consumed):
82
+ self._done = RatioLimitError(
83
+ f"output of {self._produced} bytes from {self._meter.consumed} input bytes",
84
+ layer=self._layer,
85
+ limit=self._limits.max_ratio,
86
+ )
87
+ return data
88
+
89
+ def _replay(self) -> bytes:
90
+ if self._done is _END:
91
+ return b""
92
+ assert isinstance(self._done, SpillgateError)
93
+ raise fresh(self._done)
94
+
95
+ def _probe(self) -> bytes:
96
+ """The budget is used up: is there one more byte? (SG-R-LAY-03)
97
+
98
+ If the codec yields a byte, the limit is exceeded and the byte is thrown
99
+ away. If the stream ends cleanly the output was exactly at the limit and
100
+ all is well. The codec's own validation, such as a trailer checksum, still
101
+ runs inside this call and can still report corruption.
102
+ """
103
+ data = self._call_codec(1)
104
+ if data:
105
+ self._done = OutputLimitError(
106
+ f"the layer would produce more than {self._limits.max_output} bytes",
107
+ layer=self._layer,
108
+ limit=self._limits.max_output,
109
+ )
110
+ else:
111
+ self._done = _END
112
+ return self._replay()
113
+
114
+ def _call_codec(self, n: int) -> bytes:
115
+ """One codec read, with its failures contained (SG-R-CDC-08)."""
116
+ try:
117
+ data = self._as_bytes(self._codec.read(n), n)
118
+ except SpillgateError as error:
119
+ failure = error.with_layer(self._layer)
120
+ self._done = fresh(failure)
121
+ try:
122
+ raise failure from failure.__cause__
123
+ finally:
124
+ del failure # the traceback would otherwise keep this frame, and the guard, alive
125
+ except Exception as error:
126
+ failure = self._classify(error)
127
+ self._done = fresh(failure)
128
+ try:
129
+ raise failure from (failure.__cause__ or error)
130
+ finally:
131
+ del failure
132
+ return data
133
+
134
+ def _as_bytes(self, data: object, n: int) -> bytes:
135
+ """Check the codec's return value once, by its size in *bytes* (SG-R-CDC-08).
136
+
137
+ ``len()`` of a ``memoryview`` counts items, not bytes, so the size is taken from
138
+ ``nbytes`` of a byte view; the delivered value is a plain ``bytes`` copy.
139
+ """
140
+ if not isinstance(data, bytes | bytearray | memoryview):
141
+ raise InternalError(
142
+ f"codec returned {type(data).__name__}, not bytes", layer=self._layer
143
+ )
144
+ view = memoryview(data)
145
+ if view.nbytes > n:
146
+ raise InternalError("codec returned more bytes than requested", layer=self._layer)
147
+ return view.tobytes()
148
+
149
+ def _classify(self, error: Exception) -> SpillgateError:
150
+ """An unexpected exception is a codec bug, unless the source caused it."""
151
+ source_failure = self._meter.source_failure()
152
+ if source_failure is not None:
153
+ return source_failure.with_layer(self._layer)
154
+ return InternalError(f"codec raised {type(error).__name__}", layer=self._layer)
spillgate/_gzip.py ADDED
@@ -0,0 +1,128 @@
1
+ """gzip (RFC 1952), SG-R-CDC-02.
2
+
3
+ The framing is parsed here, not by the ``gzip`` module, so every size is bounded
4
+ and every rule of the specification is explicit: the DEFLATE body is decoded by
5
+ :class:`~spillgate._inflate.RawInflater`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import zlib
11
+
12
+ from ._inflate import RawInflater
13
+ from ._meter import InputMeter
14
+ from .errors import CorruptError
15
+
16
+ _ID1, _ID2, _CM_DEFLATE = 0x1F, 0x8B, 8
17
+ _FLAG_HCRC, _FLAG_EXTRA, _FLAG_NAME, _FLAG_COMMENT = 1 << 1, 1 << 2, 1 << 3, 1 << 4
18
+ _RESERVED_FLAGS = 0xE0 # RFC 1952 requires an error if any is set
19
+ _FIXED_HEADER = 10
20
+ _TRAILER = 8
21
+ _MAX_STRING = 512 # FNAME and FCOMMENT, terminator included (SG-R-CDC-02)
22
+ _MASK32 = 0xFFFFFFFF
23
+
24
+
25
+ class GzipCodec:
26
+ """The built-in ``gzip`` codec."""
27
+
28
+ def new_reader(self, source: InputMeter) -> GzipReader:
29
+ return GzipReader(source)
30
+
31
+
32
+ class GzipReader:
33
+ """Decodes a sequence of gzip members that share one output budget."""
34
+
35
+ def __init__(self, source: InputMeter) -> None:
36
+ self._source = source
37
+ self._body: RawInflater | None = None
38
+ self._crc = 0
39
+ self._size = 0 # modulo 2**32
40
+ self._pending_trailer = False # the body ended; its trailer is checked on the next read
41
+
42
+ def read(self, n: int) -> bytes:
43
+ if self._body is None:
44
+ self._start_member(first=True) # the first member is mandatory
45
+ while True:
46
+ if self._pending_trailer:
47
+ self._pending_trailer = False
48
+ if not self._finish_member():
49
+ return b"" # clean end after the last member
50
+ assert self._body is not None
51
+ data = self._body.read(n)
52
+ if data:
53
+ self._crc = zlib.crc32(data, self._crc)
54
+ self._size = (self._size + len(data)) & _MASK32
55
+ return data
56
+ # The DEFLATE body ended. Its trailer is checked now, but bytes already
57
+ # produced were delivered first, as in the reference implementation.
58
+ self._pending_trailer = True
59
+
60
+ def close(self) -> None:
61
+ self._body = None
62
+
63
+ # -- members ---------------------------------------------------------------
64
+
65
+ def _finish_member(self) -> bool:
66
+ """Check the trailer; start the next member. False means a clean end."""
67
+ trailer = self._source.take_exact(_TRAILER, "gzip trailer")
68
+ if int.from_bytes(trailer[:4], "little") != self._crc:
69
+ raise CorruptError("gzip: checksum mismatch")
70
+ if int.from_bytes(trailer[4:], "little") != self._size:
71
+ raise CorruptError("gzip: size mismatch") # ISIZE is checked, never trusted
72
+ self._crc = self._size = 0
73
+ return self._start_member(first=False)
74
+
75
+ def _start_member(self, *, first: bool) -> bool:
76
+ """Parse a member header. False means no further member (clean end)."""
77
+ if not first:
78
+ probe = self._source.take(1)
79
+ if not probe:
80
+ return False
81
+ self._source.unread(probe)
82
+ fixed = self._source.take_exact(_FIXED_HEADER, "gzip header")
83
+ if fixed[0] != _ID1 or fixed[1] != _ID2 or fixed[2] != _CM_DEFLATE:
84
+ raise CorruptError("gzip: invalid header")
85
+ flags = fixed[3]
86
+ if flags & _RESERVED_FLAGS:
87
+ raise CorruptError("gzip: reserved header flags are set")
88
+
89
+ header_crc = zlib.crc32(fixed)
90
+ if flags & _FLAG_EXTRA:
91
+ header_crc = self._skip_extra(header_crc)
92
+ if flags & _FLAG_NAME:
93
+ header_crc = self._skip_string(header_crc, "name")
94
+ if flags & _FLAG_COMMENT:
95
+ header_crc = self._skip_string(header_crc, "comment")
96
+ if flags & _FLAG_HCRC:
97
+ stored = int.from_bytes(self._source.take_exact(2, "gzip header checksum"), "little")
98
+ if stored != header_crc & 0xFFFF:
99
+ raise CorruptError("gzip: header checksum mismatch")
100
+
101
+ self._body = RawInflater(self._source, "gzip")
102
+ return True
103
+
104
+ def _skip_extra(self, header_crc: int) -> int:
105
+ """FEXTRA: at most 65535 bytes by format, read in small pieces (SG-R-MEM-01)."""
106
+ length_bytes = self._source.take_exact(2, "gzip extra field")
107
+ header_crc = zlib.crc32(length_bytes, header_crc)
108
+ remaining = int.from_bytes(length_bytes, "little")
109
+ while remaining > 0:
110
+ chunk = self._source.take_exact(min(remaining, _MAX_STRING), "gzip extra field")
111
+ header_crc = zlib.crc32(chunk, header_crc)
112
+ remaining -= len(chunk)
113
+ return header_crc
114
+
115
+ def _skip_string(self, header_crc: int, what: str) -> int:
116
+ """FNAME / FCOMMENT: zero-terminated, at most 512 bytes including the terminator."""
117
+ collected = bytearray()
118
+ while len(collected) < _MAX_STRING:
119
+ chunk = self._source.take(_MAX_STRING - len(collected))
120
+ if not chunk:
121
+ raise CorruptError(f"gzip: header {what} ends too early")
122
+ end = chunk.find(b"\0")
123
+ if end >= 0:
124
+ self._source.unread(chunk[end + 1 :])
125
+ collected += chunk[: end + 1]
126
+ return zlib.crc32(bytes(collected), header_crc)
127
+ collected += chunk
128
+ raise CorruptError(f"gzip: header {what} is longer than {_MAX_STRING - 1} bytes")
spillgate/_inflate.py ADDED
@@ -0,0 +1,71 @@
1
+ """A bounded raw-DEFLATE decoder on top of ``zlib.decompressobj``.
2
+
3
+ The classic Python trap is ``decompressobj().decompress(data)`` without
4
+ ``max_length``: it expands the whole input at once. Here every call passes a
5
+ ``max_length`` greater than zero (``0`` would mean *unlimited*), and input the
6
+ decoder did not use is handed back to the :class:`~spillgate._meter.InputMeter`, so
7
+ the meter counts only what the decoder really consumed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import zlib
13
+
14
+ from ._meter import InputMeter
15
+ from .errors import CorruptError
16
+
17
+ _FEED = 4096 # compressed bytes offered per step; the rest is handed back
18
+
19
+
20
+ class RawInflater:
21
+ """Decodes one raw DEFLATE stream (RFC 1951) from a meter.
22
+
23
+ ``read(n)`` returns 1..n bytes, or ``b""`` once the final block was decoded.
24
+ After that the bytes behind the stream are back in the meter, exactly where a
25
+ trailer (gzip, zlib) or the next member starts.
26
+ """
27
+
28
+ def __init__(self, source: InputMeter, what: str) -> None:
29
+ self._source = source
30
+ self._what = what
31
+ self._decoder = zlib.decompressobj(-zlib.MAX_WBITS)
32
+ self._may_have_output = False # the last call filled its buffer completely
33
+
34
+ @property
35
+ def finished(self) -> bool:
36
+ return self._decoder.eof
37
+
38
+ def read(self, n: int) -> bytes:
39
+ """Return decoded bytes; ``b""`` means the stream has ended (its trailer is next).
40
+
41
+ Raises:
42
+ CorruptError: invalid DEFLATE data, or the input ends before the final block.
43
+ """
44
+ if n < 1:
45
+ raise ValueError("n must be at least 1") # max_length 0 would mean unlimited
46
+ decoder = self._decoder
47
+ while not decoder.eof:
48
+ # A full buffer last time means output may still be pending inside zlib
49
+ # even though all input was used, so ask again before taking more input.
50
+ data = b"" if self._may_have_output else self._source.take(_FEED)
51
+ if not data and not self._may_have_output:
52
+ raise CorruptError(f"{self._what}: the stream ends too early")
53
+ out = self._decompress(data, n)
54
+ self._may_have_output = len(out) == n
55
+ if out:
56
+ return out
57
+ return b""
58
+
59
+ def _decompress(self, data: bytes, n: int) -> bytes:
60
+ decoder = self._decoder
61
+ try:
62
+ out = decoder.decompress(data, n)
63
+ except zlib.error as error:
64
+ raise CorruptError(f"{self._what}: invalid DEFLATE data ({error})") from None
65
+ # Bytes zlib has not used go back, so "consumed" stays exact: after the
66
+ # final block that is the trailer, before it the input held back by max_length.
67
+ leftover = decoder.unused_data if decoder.eof else decoder.unconsumed_tail
68
+ if leftover:
69
+ # zlib has not seen the unconsumed tail yet; it is taken from the meter again.
70
+ self._source.unread(bytes(leftover))
71
+ return out