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 +77 -0
- spillgate/_cancel.py +25 -0
- spillgate/_chain.py +117 -0
- spillgate/_deflate.py +89 -0
- spillgate/_guard.py +154 -0
- spillgate/_gzip.py +128 -0
- spillgate/_inflate.py +71 -0
- spillgate/_meter.py +204 -0
- spillgate/br.py +203 -0
- spillgate/codec.py +44 -0
- spillgate/decoder.py +201 -0
- spillgate/errors.py +224 -0
- spillgate/header.py +106 -0
- spillgate/limits.py +141 -0
- spillgate/py.typed +0 -0
- spillgate/zstd.py +275 -0
- spillgate-0.1.0.dist-info/METADATA +176 -0
- spillgate-0.1.0.dist-info/RECORD +20 -0
- spillgate-0.1.0.dist-info/WHEEL +4 -0
- spillgate-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|