Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/upcoming_changes/10761.bug_fix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Fix ``np.clip`` writing past the end of a caller-provided ``out`` array
-----------------------------------------------------------------------

``np.clip`` did not check the shape of an explicit ``out`` argument, so an
``out`` that did not match the shape of the result was written past its end
and a wrong array was returned with no error. The shape is now validated and
a mismatch raises ``ValueError``, matching NumPy.
35 changes: 29 additions & 6 deletions numba/np/arrayobj.py
Original file line number Diff line number Diff line change
Expand Up @@ -2644,11 +2644,26 @@ def imp(ary):
return res


@register_jitable
def _np_clip_prepare_out(a, shape, out):
# Returns the array the result is written into. NumPy broadcasts the
# inputs onto a caller-provided output but never stretches the output
# itself, so its shape has to match the shape of the result exactly.
# Without this check the callers loop over the result shape and write
# past the end of a too-small `out` (issue #10682).
if out is None:
return np.empty_like(a)
if out.shape != shape:
raise ValueError("clip: the shape of the 'out' array does not "
"match the shape of the result")
return out


@register_jitable
def _np_clip_impl(a, a_min, a_max, out):
# Both a_min and a_max are numpy arrays
ret = np.empty_like(a) if out is None else out
a_b, a_min_b, a_max_b = np.broadcast_arrays(a, a_min, a_max)
ret = _np_clip_prepare_out(a, a_b.shape, out)
for index in np.ndindex(a_b.shape):
val_a = a_b[index]
val_a_min = a_min_b[index]
Expand Down Expand Up @@ -2689,6 +2704,14 @@ def np_clip(a, a_min, a_max, out=None):
msg = 'The argument "out" must be an array if it is provided'
raise errors.TypingError(msg)

# The result has as many dimensions as the widest operand, and `out` is
# never broadcast, so a mismatch here can be reported at compile time.
result_ndim = max(getattr(t, 'ndim', 0) for t in (a, a_min, a_max))
if isinstance(out, types.Array) and out.ndim != result_ndim:
msg = ('The argument "out" must have the same number of dimensions '
'as the result')
raise errors.TypingError(msg)

# TODO: support scalar a (issue #3469)
a_min_is_none = a_min is None or isinstance(a_min, types.NoneType)
a_max_is_none = a_max is None or isinstance(a_max, types.NoneType)
Expand All @@ -2708,7 +2731,7 @@ def np_clip_ss(a, a_min, a_max, out=None):
# a_min and a_max are scalars
# since their shape will be empty
# so broadcasting is not needed at all
ret = np.empty_like(a) if out is None else out
ret = _np_clip_prepare_out(a, a.shape, out)
for index in np.ndindex(a.shape):
val_a = a[index]
ret[index] = min(max(val_a, a_min), a_max)
Expand All @@ -2722,7 +2745,7 @@ def np_clip_sn(a, a_min, a_max, out=None):
# a_min is a scalar
# since its shape will be empty
# so broadcasting is not needed at all
ret = np.empty_like(a) if out is None else out
ret = _np_clip_prepare_out(a, a.shape, out)
for index in np.ndindex(a.shape):
val_a = a[index]
ret[index] = max(val_a, a_min)
Expand All @@ -2746,7 +2769,7 @@ def np_clip_ns(a, a_min, a_max, out=None):
# a_max is a scalar
# since its shape will be empty
# so broadcasting is not needed at all
ret = np.empty_like(a) if out is None else out
ret = _np_clip_prepare_out(a, a.shape, out)
for index in np.ndindex(a.shape):
val_a = a[index]
ret[index] = min(val_a, a_max)
Expand All @@ -2769,16 +2792,16 @@ def np_clip_as(a, a_min, a_max, out=None):
if a_min_is_none:
def np_clip_na(a, a_min, a_max, out=None):
# a_max is a numpy array but a_min is None
ret = np.empty_like(a) if out is None else out
a_b, a_max_b = np.broadcast_arrays(a, a_max)
ret = _np_clip_prepare_out(a, a_b.shape, out)
return _np_clip_impl_none(a_b, a_max_b, True, ret)

return np_clip_na
elif a_max_is_none:
def np_clip_an(a, a_min, a_max, out=None):
# a_min is a numpy array but a_max is None
ret = np.empty_like(a) if out is None else out
a_b, a_min_b = np.broadcast_arrays(a, a_min)
ret = _np_clip_prepare_out(a, a_b.shape, out)
return _np_clip_impl_none(a_b, a_min_b, False, ret)

return np_clip_an
Expand Down
50 changes: 50 additions & 0 deletions numba/tests/test_array_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -1820,6 +1820,56 @@ def test_clip_min_max_errors(self):
with self.assertRaisesRegex(ValueError, msg):
cfunc(a, None, None)

def test_clip_out_shape_mismatch(self):
# Issue #10682: an `out` that does not match the shape of the result
# used to be written past its end, silently returning a wrong array.
# NumPy raises instead, because it never broadcasts `out` itself.
# Disable leak check since we expect an error to be raised
self.disable_leak_check()

has_out = (np_clip, np_clip_kwargs, array_clip, array_clip_kwargs)

a = np.linspace(-10, 10, 5)
a_min_arr = np.zeros_like(a)
a_max_arr = np.full_like(a, 5)
# covers every implementation branch: scalar/scalar, scalar/None,
# None/scalar, scalar/array, array/scalar, None/array, array/None
# and array/array
min_max = ((-5, 5), (-5, None), (None, 5), (-5, a_max_arr),
(a_min_arr, 5), (None, a_max_arr), (a_min_arr, None),
(a_min_arr, a_max_arr))

msg = "clip: the shape of the 'out' array does not match"
for pyfunc in has_out:
cfunc = jit(nopython=True)(pyfunc)
for a_min, a_max in min_max:
# too small, and too large: NumPy rejects both
for bad in (np.empty(a.size - 1), np.empty(a.size + 1)):
with self.assertRaisesRegex(ValueError, msg):
cfunc(a, a_min, a_max, bad)

def test_clip_out_no_out_of_bounds_write(self):
# Issue #10682: writing past the end of a too-small `out` corrupted
# whatever followed it in memory. Clip into a view of a larger
# buffer and check the rest of that buffer is left alone.
# Disable leak check since we expect an error to be raised
self.disable_leak_check()

cfunc = jit(nopython=True)(np_clip)

a = np.arange(5.0)
buf = np.full(8, 999.0)
with self.assertRaises(ValueError):
cfunc(a, 0.0, 3.0, buf[:3])

np.testing.assert_equal(buf, np.full(8, 999.0))

def test_clip_out_bad_ndim(self):
cfunc = jit(nopython=True)(np_clip)
msg = '.*The argument "out" must have the same number of dimensions.*'
with self.assertRaisesRegex(TypingError, msg):
cfunc(np.arange(5.0), 0.0, 3.0, np.empty((2, 5)))

def test_clip_bad_array(self):
cfunc = jit(nopython=True)(np_clip)
msg = '.*The argument "a" must be array-like.*'
Expand Down
Loading