Skip to content

Commit 28d5f8f

Browse files
FrancescAltedclaude
andcommitted
utf8: address Copilot review comments on PR #677
- pack_utf8_span (utf8_ext.pyx): validate rel is well-formed (rel[0]==0, non-decreasing, within data's bounds) before the unchecked C loop, instead of trusting the caller silently -- a malformed rel previously risked an out-of-bounds read/crash. - Utf8Factorizer docstring: stop claiming a hash collision's duplicate vocabulary entry is "benign because callers merge groups by decoded value" -- no downstream consumer does that merge, so a collision would actually split a group in two. Still an accepted, vanishingly-rare risk with 64-bit hashes, just not a resolved one. - test_utf8.py: two groupby tests assigned float64 values into the int64 `x` column via implicit cast; use integer inputs explicitly so the test doesn't rely on that coercion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 22aafbf commit 28d5f8f

3 files changed

Lines changed: 42 additions & 7 deletions

File tree

src/blosc2/utf8_array.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -669,10 +669,14 @@ class Utf8Factorizer:
669669
first-appearance order; :meth:`uniques` returns the decoded vocabulary in
670670
that same order. No row is ever decoded — only the distinct values.
671671
672-
A hash collision between a new value and a known one is not resolved
673-
(the new value gets a fresh code); the resulting duplicate vocabulary
674-
entry is benign because callers merge groups by decoded value, and with
675-
64-bit hashes the case is vanishingly rare anyway.
672+
A hash collision between a new value and an already-known value of the
673+
same byte length is not resolved: the new value always gets a fresh
674+
code, even in the (unhandled) case where its decoded string equals an
675+
existing vocabulary entry's. No downstream consumer merges groups by
676+
decoded value, so such a collision would silently split what should be
677+
one group into two rather than being caught -- an accepted, unmitigated
678+
risk given how vanishingly rare it is with 64-bit hashes, not a
679+
guarantee that it is otherwise resolved.
676680
"""
677681

678682
def __init__(self, arr: Utf8Array) -> None:

src/blosc2/utf8_ext.pyx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ def pack_utf8_span(cnp.ndarray rel not None, cnp.ndarray data not None, cnp.ndar
8080
raise ValueError("rel, data and out must be C-contiguous")
8181
if n == 0:
8282
return
83+
# Cheap, vectorized well-formedness checks: a malformed rel (decreasing,
84+
# negative, or reaching past the end of data) would otherwise drive the
85+
# unchecked pointer arithmetic below out of bounds.
86+
if int(rel[0]) != 0:
87+
raise ValueError("rel[0] must be 0")
88+
if bool((np.diff(rel) < 0).any()):
89+
raise ValueError("rel must be non-decreasing")
90+
if int(rel[n]) > data.shape[0]:
91+
raise ValueError("rel values must not exceed len(data)")
8392

8493
cdef const int64_t* rel_ptr = <const int64_t*>cnp.PyArray_DATA(rel)
8594
cdef const uint8_t* data_ptr = <const uint8_t*>cnp.PyArray_DATA(data)

tests/ctable/test_utf8.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,28 @@ def force_kernel_mode(request, monkeypatch):
259259
return request.param
260260

261261

262+
def test_pack_utf8_span_rejects_malformed_rel():
263+
"""pack_utf8_span trusts its caller's rel/data invariants for speed, but
264+
still validates them cheaply up front so a malformed rel fails with a
265+
clear ValueError instead of driving the unchecked C loop out of bounds."""
266+
pytest.importorskip("blosc2.utf8_ext")
267+
from blosc2 import utf8_ext
268+
269+
data = np.array([1, 2, 3], dtype=np.uint8)
270+
out = np.empty(2, dtype=STRING_DTYPE)
271+
272+
with pytest.raises(ValueError, match="rel\\[0\\] must be 0"):
273+
utf8_ext.pack_utf8_span(np.array([1, 2, 3], dtype=np.int64), data, out)
274+
with pytest.raises(ValueError, match="non-decreasing"):
275+
utf8_ext.pack_utf8_span(np.array([0, 2, 1], dtype=np.int64), data, out)
276+
with pytest.raises(ValueError, match="must not exceed len\\(data\\)"):
277+
utf8_ext.pack_utf8_span(np.array([0, 2, 10], dtype=np.int64), data, out)
278+
279+
# a well-formed rel still works after the added checks
280+
utf8_ext.pack_utf8_span(np.array([0, 1, 3], dtype=np.int64), data, out)
281+
assert list(out) == ["\x01", "\x02\x03"]
282+
283+
262284
def test_utf8_array_bulk_read_kernel_and_fallback(force_kernel_mode):
263285
from blosc2.utf8_array import Utf8Array
264286

@@ -874,7 +896,7 @@ def test_ctable_utf8_groupby_many_byte_lengths_and_non_ascii():
874896
pool = ["", "a", "bb", "café", "日本語のテキスト", "x" * 2000, "münchen"]
875897
names = [pool[i] for i in rng.integers(0, len(pool), 3000)]
876898
t = make_table(names)
877-
t.x[:] = np.ones(3000)
899+
t.x[:] = np.ones(3000, dtype=np.int64)
878900
g = t.group_by("name").sum("x")
879901
got = dict(zip(g["name"][:].tolist(), g["x_sum"][:].tolist(), strict=True))
880902
exp: dict[str, int] = {}
@@ -927,10 +949,10 @@ class FloatRow:
927949

928950
def test_ctable_utf8_groupby_sum():
929951
t = make_table(["a", "b", "a", "b", "a"])
930-
t.x[:] = [1.0, 2.0, 3.0, 4.0, 5.0]
952+
t.x[:] = [1, 2, 3, 4, 5]
931953
g = t.group_by("name").sum("x")
932954
rows = dict(zip(g["name"][:].tolist(), g["x_sum"][:].tolist(), strict=False))
933-
assert rows == {"a": 9.0, "b": 6.0}
955+
assert rows == {"a": 9, "b": 6}
934956

935957

936958
def test_ctable_utf8_groupby_size_and_dropna():

0 commit comments

Comments
 (0)