Skip to content

Commit cfcf8b9

Browse files
authored
Merge pull request #286 from ptweezy/c-error-paths
Fix three out-of-memory crashes in the C extensions
2 parents e11b4ff + 93fe0e2 commit cfcf8b9

6 files changed

Lines changed: 122 additions & 14 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ version 1.8.1-dev
109109
``igzip.IGzipFile`` was not affected.
110110
+ Fix a crash when there is not enough memory to create an
111111
``igzip_lib.IgzipDecompressor``. It raises ``MemoryError`` now.
112+
+ Fix a crash when there is not enough memory to raise ``BadGzipFile`` for
113+
data that is not in the gzip format, for example, in ``igzip.decompress``.
114+
It raises ``MemoryError`` now.
115+
+ Fix a crash in ``igzip_lib.IgzipDecompressor.decompress()`` after a call on
116+
the same object raised ``MemoryError``. The next call used the input of the
117+
failed call after the caller had released it. This matches the CPython fix
118+
for gh-148395.
119+
+ Fix a crash on macOS and PyPy when there is not enough memory to create the
120+
internal lock of an ``isal_zlib.Compress``, ``isal_zlib.Decompress``, or
121+
``igzip_lib.IgzipDecompressor`` object. It raises ``MemoryError`` now.
112122

113123
version 1.8.0
114124
-----------------

‎src/isal/igzip_libmodule.c‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ typedef struct {
4444
static void
4545
IgzipDecompressor_dealloc(IgzipDecompressor *self)
4646
{
47-
PyThread_free_lock(self->lock);
47+
if (self->lock != NULL) {
48+
PyThread_free_lock(self->lock);
49+
}
4850
PyMem_Free(self->input_buffer);
4951
Py_CLEAR(self->unused_data);
5052
Py_CLEAR(self->zdict);
@@ -298,6 +300,10 @@ decompress(IgzipDecompressor *self, uint8_t *data, size_t len, Py_ssize_t max_le
298300
return result;
299301

300302
error:
303+
/* next_in can point to the buffer of the caller, which is released after
304+
this call. Reset it so the next call does not use it, as CPython does
305+
since gh-148395. */
306+
self->state.next_in = NULL;
301307
Py_XDECREF(result);
302308
return NULL;
303309
}
@@ -480,6 +486,7 @@ igzip_lib_IgzipDecompressor__new__(PyTypeObject *type,
480486
self->avail_in_real = 0;
481487
self->input_buffer = NULL;
482488
self->input_buffer_size = 0;
489+
self->lock = NULL;
483490
Py_XINCREF(zdict);
484491
self->zdict = zdict;
485492
self->unused_data = PyBytes_FromStringAndSize(NULL, 0);

‎src/isal/isal_zlibmodule.c‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,9 @@ Comp_dealloc(compobject *self)
594594
{
595595
if (self->is_initialised && self->level_buf != NULL)
596596
PyMem_Free(self->level_buf);
597-
PyThread_free_lock(self->lock);
597+
if (self->lock != NULL) {
598+
PyThread_free_lock(self->lock);
599+
}
598600
Py_XDECREF(self->zdict);
599601
Py_TYPE(self)->tp_free((PyObject *)self);
600602
}
@@ -639,7 +641,9 @@ typedef struct
639641
static void
640642
Decomp_dealloc(decompobject *self)
641643
{
642-
PyThread_free_lock(self->lock);
644+
if (self->lock != NULL) {
645+
PyThread_free_lock(self->lock);
646+
}
643647
Py_XDECREF(self->unused_data);
644648
Py_XDECREF(self->unconsumed_tail);
645649
Py_XDECREF(self->zdict);
@@ -683,6 +687,9 @@ newdecompobject()
683687
self->is_initialised = 0;
684688
self->method_set = 0;
685689
self->zdict = NULL;
690+
// Decomp_dealloc uses these members when initialization fails
691+
self->unconsumed_tail = NULL;
692+
self->lock = NULL;
686693
self->unused_data = PyBytes_FromStringAndSize("", 0);
687694
if (self->unused_data == NULL) {
688695
Py_DECREF(self);
@@ -693,7 +700,7 @@ newdecompobject()
693700
Py_DECREF(self);
694701
return NULL;
695702
}
696-
self->lock = PyThread_allocate_lock();
703+
self->lock = PyThread_allocate_lock();
697704
if (self->lock == NULL) {
698705
Py_DECREF(self);
699706
PyErr_SetString(PyExc_MemoryError, "Unable to allocate lock");
@@ -1578,7 +1585,7 @@ static PyGetSetDef Decomp_properties[] = {
15781585

15791586
static PyTypeObject IsalZlibDecompType = {
15801587
.tp_name = "isal_zlib.Decompress",
1581-
.tp_doc = "Object returned by isal_zlib.compressobj.",
1588+
.tp_doc = "Object returned by isal_zlib.decompressobj.",
15821589
.tp_basicsize = sizeof(decompobject),
15831590
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_DISALLOW_INSTANTIATION,
15841591
.tp_dealloc = (destructor)Decomp_dealloc,
@@ -1845,6 +1852,9 @@ GzipReader_read_into_buffer(GzipReader *self, uint8_t *out_buffer, size_t out_bu
18451852
if (!(magic1 == 0x1f && magic2 == 0x8b)) {
18461853
Py_BLOCK_THREADS;
18471854
PyObject *magic_obj = PyBytes_FromStringAndSize((char *)current_pos, 2);
1855+
if (magic_obj == NULL) {
1856+
return -1;
1857+
}
18481858
raise_bad_gzip_file(
18491859
"Not a gzipped file (%R)",
18501860
magic_obj);

‎tests/test_compat.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import gzip
1313
import itertools
1414
import random
15+
import subprocess
16+
import sys
17+
import textwrap
1518
import threading
1619
import warnings
1720
import zlib
@@ -66,6 +69,28 @@ def test_compress_and_decompress_types_cannot_be_called(factory):
6669
type(factory())()
6770

6871

72+
@pytest.mark.skipif(sys.implementation.name != "cpython",
73+
reason="Needs _testcapi.set_nomemory")
74+
@pytest.mark.parametrize("factory", ["compressobj", "decompressobj"])
75+
def test_compressobj_decompressobj_no_memory(factory):
76+
pytest.importorskip("_testcapi")
77+
# Fail each memory allocation in turn, such as the one for the lock. A
78+
# subprocess keeps the failing allocations away from the test runner.
79+
code = textwrap.dedent(f"""
80+
import _testcapi
81+
from isal import isal_zlib
82+
for start in range(20):
83+
_testcapi.set_nomemory(start, start + 1)
84+
try:
85+
isal_zlib.{factory}()
86+
except MemoryError:
87+
pass
88+
finally:
89+
_testcapi.remove_mem_hooks()
90+
""")
91+
subprocess.run([sys.executable, "-c", code], check=True)
92+
93+
6994
@pytest.mark.parametrize(["data_size", "level"],
7095
itertools.product(DATA_SIZES, range(4)))
7196
def test_compress(data_size, level):

‎tests/test_igzip.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import subprocess
2020
import sys
2121
import tempfile
22+
import textwrap
2223
import zlib
2324
from gzip import FCOMMENT, FEXTRA, FHCRC, FNAME, FTEXT # type: ignore
2425
from pathlib import Path
@@ -457,6 +458,28 @@ def test_decompress_not_a_gzip():
457458
assert error.match(re.escape("Not a gzipped file (b'Th')"))
458459

459460

461+
@pytest.mark.skipif(sys.implementation.name != "cpython",
462+
reason="Needs _testcapi.set_nomemory")
463+
def test_decompress_not_a_gzip_no_memory():
464+
pytest.importorskip("_testcapi")
465+
# Fail each memory allocation in turn, such as the one for the bytes in
466+
# the error message. A subprocess keeps the failing allocations away from
467+
# the test runner. Each call raises MemoryError or BadGzipFile.
468+
code = textwrap.dedent("""
469+
import _testcapi
470+
from isal import igzip
471+
for start in range(50):
472+
_testcapi.set_nomemory(start, start + 1)
473+
try:
474+
igzip.decompress(b"This is not a gzip data stream.")
475+
except (MemoryError, igzip.BadGzipFile):
476+
pass
477+
finally:
478+
_testcapi.remove_mem_hooks()
479+
""")
480+
subprocess.run([sys.executable, "-c", code], check=True)
481+
482+
460483
def test_decompress_unknown_compression_method():
461484
corrupted_data = COMPRESSED_DATA[:2] + b'\x09' + COMPRESSED_DATA[3:]
462485
with pytest.raises(igzip.BadGzipFile) as error:

‎tests/test_igzip_lib.py‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,51 @@ def test_igzip_decompressor_keeps_zdict_reference():
299299
reason="Needs _testcapi.set_nomemory")
300300
def test_igzip_decompressor_no_memory():
301301
pytest.importorskip("_testcapi")
302-
# Fail the first memory allocation, which is the object itself. A
303-
# subprocess keeps the failing allocation away from the test runner.
302+
# Fail each memory allocation in turn, such as the one for the object
303+
# itself or for its lock. A subprocess keeps the failing allocations away
304+
# from the test runner.
304305
code = textwrap.dedent("""
305306
import _testcapi
306307
from isal.igzip_lib import IgzipDecompressor
307-
_testcapi.set_nomemory(0, 1)
308-
try:
309-
IgzipDecompressor()
310-
except MemoryError:
311-
pass
312-
finally:
313-
_testcapi.remove_mem_hooks()
308+
for start in range(20):
309+
_testcapi.set_nomemory(start, start + 1)
310+
try:
311+
IgzipDecompressor()
312+
except MemoryError:
313+
pass
314+
finally:
315+
_testcapi.remove_mem_hooks()
316+
""")
317+
subprocess.run([sys.executable, "-c", code], check=True)
318+
319+
320+
@pytest.mark.skipif(sys.implementation.name != "cpython",
321+
reason="Needs _testcapi.set_nomemory")
322+
def test_igzip_decompressor_decompress_no_memory():
323+
pytest.importorskip("_testcapi")
324+
# After a MemoryError, the next call must not use the input of the failed
325+
# call. That input is in an mmap, so using it after close() crashes.
326+
code = textwrap.dedent("""
327+
import mmap
328+
import os
329+
import _testcapi
330+
from isal import igzip_lib
331+
# Random data does not compress, so most of the input is left over
332+
# after 10 bytes of output. decompress() copies it to a new buffer.
333+
compressed = igzip_lib.compress(os.urandom(256 * 1024))
334+
for start in range(20):
335+
decompressor = igzip_lib.IgzipDecompressor()
336+
buffer = mmap.mmap(-1, len(compressed))
337+
buffer.write(compressed)
338+
_testcapi.set_nomemory(start, start + 1)
339+
try:
340+
decompressor.decompress(buffer, 10)
341+
except MemoryError:
342+
pass
343+
finally:
344+
_testcapi.remove_mem_hooks()
345+
buffer.close()
346+
decompressor.decompress(b"")
314347
""")
315348
subprocess.run([sys.executable, "-c", code], check=True)
316349

0 commit comments

Comments
 (0)