Skip to content

Commit e051e36

Browse files
gh-155460: Pre-allocate the output buffer in compression.zstd decompression
Frames produced by the one-shot compression APIs record the decompressed size in the frame header. When the input starts at such a frame header, read that size with ZSTD_getFrameContentSize() and allocate the output buffer at its exact size, instead of growing it progressively and shrinking it on finish. An exactly filled buffer is then returned without a copy. This is only a sizing hint: decompression does not rely on it, so a hand-crafted header recording a wrong size produces exactly the same results and exceptions as before. Two guards bound the allocation: the recorded size is trusted only up to 1 GiB, and recorded sizes claiming more than a 32768x expansion of the available input -- more than the format can produce -- are ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5107fd7 commit e051e36

3 files changed

Lines changed: 142 additions & 2 deletions

File tree

Lib/test/test_zstd.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,106 @@ def test_decompress_empty_content_frame(self):
830830
self.assertEqual(d.unused_data, b'')
831831
self.assertEqual(d.unused_data, b'') # twice
832832

833+
@staticmethod
834+
def _patch_content_size(frame, new_size):
835+
# Rewrite the Frame_Content_Size field of a frame header, see
836+
# RFC 8878 section 3.1.1.1.
837+
frame_header_descriptor = frame[4]
838+
fcs_flag = frame_header_descriptor >> 6
839+
single_segment = (frame_header_descriptor >> 5) & 1
840+
did_field_size = (0, 1, 2, 4)[frame_header_descriptor & 3]
841+
offset = 5 + (0 if single_segment else 1) + did_field_size
842+
fcs_field_size = (1 if single_segment else 0, 2, 4, 8)[fcs_flag]
843+
if fcs_field_size == 2:
844+
new_size -= 256
845+
patched = bytearray(frame)
846+
patched[offset:offset+fcs_field_size] = \
847+
new_size.to_bytes(fcs_field_size, 'little')
848+
return bytes(patched)
849+
850+
def test_decompress_wrong_content_size(self):
851+
# The decompressed size recorded in the frame header is used to
852+
# pre-allocate the output buffer, so decompressing frames whose
853+
# recorded size does not match the real one (only possible with
854+
# hand-crafted frames) deserves extra attention.
855+
frame = compress(DAT_130K_D)
856+
self.assertEqual(get_frame_info(frame).decompressed_size, _130_1K)
857+
858+
# patching the real size back is harmless (checks the patch helper)
859+
patched = self._patch_content_size(frame, _130_1K)
860+
self.assertEqual(patched, frame)
861+
862+
for lie in (_130_1K + 1000, 1000, 0):
863+
with self.subTest(lie=lie):
864+
patched = self._patch_content_size(frame, lie)
865+
self.assertEqual(get_frame_info(patched).decompressed_size,
866+
lie)
867+
with self.assertRaises(ZstdError):
868+
decompress(patched)
869+
870+
def test_decompress_absurd_content_size(self):
871+
# A recorded size that is absurdly large for the frame's size, or
872+
# even impossible to produce from it, must not lead to a huge
873+
# pre-allocation.
874+
for data in (DAT_130K_D, # bigger than the compressed frame
875+
b'a' * 66000 # tiny compressed frame
876+
):
877+
frame = compress(data)
878+
patched = self._patch_content_size(frame, 0xFFFF_FFFF)
879+
with self.subTest(frame_size=len(frame)):
880+
self.assertEqual(get_frame_info(patched).decompressed_size,
881+
0xFFFF_FFFF)
882+
with self.assertRaises(ZstdError):
883+
decompress(patched)
884+
885+
def test_decompress_content_size_known(self):
886+
# frames whose header records the decompressed size, with sizes
887+
# around the output buffer block boundaries
888+
big_data = DAT_130K_D * 9
889+
for size in (1, 100,
890+
32*_1K - 1, 32*_1K, 32*_1K + 1,
891+
_1M + 17):
892+
with self.subTest(size=size):
893+
data = big_data[:size]
894+
frame = compress(data)
895+
self.assertEqual(get_frame_info(frame).decompressed_size,
896+
size)
897+
self.assertEqual(decompress(frame), data)
898+
899+
d = ZstdDecompressor()
900+
self.assertEqual(d.decompress(frame), data)
901+
self.assertTrue(d.eof)
902+
903+
def test_decompress_content_size_unknown(self):
904+
# streaming compression does not record the decompressed size in
905+
# the frame header
906+
c = ZstdCompressor()
907+
frame = c.compress(DAT_130K_D) + c.flush()
908+
self.assertIsNone(get_frame_info(frame).decompressed_size)
909+
self.assertEqual(decompress(frame), DAT_130K_D)
910+
911+
def test_decompress_content_size_known_max_length(self):
912+
frame = compress(DAT_130K_D)
913+
d = ZstdDecompressor()
914+
dat = d.decompress(frame, max_length=1000)
915+
self.assertEqual(len(dat), 1000)
916+
self.assertFalse(d.needs_input)
917+
while not d.eof:
918+
dat += d.decompress(b'', max_length=32*_1K)
919+
self.assertEqual(dat, DAT_130K_D)
920+
921+
def test_decompress_content_size_known_split_input(self):
922+
frame = compress(DAT_130K_D)
923+
# a split point of 3 cuts the frame header's magic number,
924+
# 18 cuts right after the (complete) frame header
925+
for split in (3, 18, len(frame) // 2):
926+
with self.subTest(split=split):
927+
d = ZstdDecompressor()
928+
dat = d.decompress(frame[:split])
929+
dat += d.decompress(frame[split:])
930+
self.assertEqual(dat, DAT_130K_D)
931+
self.assertTrue(d.eof)
932+
833933
class DecompressorFlagsTestCase(unittest.TestCase):
834934

835935
@classmethod
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Speed up :mod:`compression.zstd` decompression of frames whose header
2+
records the decompressed size (as written by the one-shot compression APIs)
3+
by allocating the output buffer at its exact size up front, instead of
4+
growing it progressively.

Modules/_zstd/decompressor.c

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,18 @@ _zstd_load_d_dict(ZstdDecompressor *self, PyObject *dict)
187187
return ret;
188188
}
189189

190+
/* Only pre-allocate an output buffer of up to this size based on the
191+
decompressed size recorded in a frame header, so that a hand-crafted
192+
header cannot request an arbitrarily large allocation. Larger outputs
193+
use the progressively growing buffer. */
194+
#define OUTPUT_PREALLOC_MAX ((Py_ssize_t)1 << 30)
195+
196+
/* A zstd block cannot expand to more than 128 KiB from less than 4 bytes
197+
of compressed input, so a valid frame never expands by more than 32768x.
198+
A recorded decompressed size claiming a higher ratio than this cannot be
199+
fulfilled by the input and is treated as untrustworthy. */
200+
#define OUTPUT_MAX_EXPANSION 32768
201+
190202
/*
191203
Decompress implementation in pseudo code:
192204
@@ -220,8 +232,32 @@ decompress_lock_held(ZstdDecompressor *self, ZSTD_inBuffer *in,
220232
_BlocksOutputBuffer buffer = {.writer = NULL};
221233
PyObject *ret;
222234

223-
/* Initialize the output buffer */
224-
if (_OutputBuffer_InitAndGrow(&buffer, &out, max_length) < 0) {
235+
/* Initialize the output buffer.
236+
237+
Frames produced by the one-shot compression APIs record the
238+
decompressed size in the frame header. When *in* starts at a frame
239+
header recording a plausible size, allocate the whole output buffer
240+
at once instead of growing it in blocks: for an exactly-filled
241+
single block, _OutputBuffer_Finish() returns it without a copy.
242+
243+
This is only a sizing hint, decompression does not rely on it: if
244+
the recorded size turns out to be wrong, the buffer grows further
245+
as needed, or is shrunk to the actual size on finish. */
246+
size_t avail_in = in->size - in->pos;
247+
unsigned long long content_size =
248+
ZSTD_getFrameContentSize((const char*)in->src + in->pos, avail_in);
249+
if (content_size != ZSTD_CONTENTSIZE_UNKNOWN
250+
&& content_size != ZSTD_CONTENTSIZE_ERROR
251+
&& 0 < content_size
252+
&& content_size <= (unsigned long long)OUTPUT_PREALLOC_MAX
253+
&& content_size / OUTPUT_MAX_EXPANSION <= avail_in)
254+
{
255+
if (_OutputBuffer_InitWithSize(&buffer, &out, max_length,
256+
(Py_ssize_t)content_size) < 0) {
257+
goto error;
258+
}
259+
}
260+
else if (_OutputBuffer_InitAndGrow(&buffer, &out, max_length) < 0) {
225261
goto error;
226262
}
227263
assert(out.pos == 0);

0 commit comments

Comments
 (0)