From 59baacfcba3b2e744d159d0d4da2d73c0549dae3 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Fri, 31 Jul 2026 12:21:19 +0200 Subject: [PATCH] Validate list and element counts; skip empty header lines A negative binary list count was never rejected: _read_array computed a negative size and stream.read() read the entire remaining file (BytesIO) or raised a misleading 'early end-of-file' (files). The same class of bug let a negative ASCII list count fall through as 'malformed input' and a negative element count leak a raw numpy ValueError. Check counts for non-negativity at the shared _read_array helper and at parse_element/_from_fields, carrying the real message through PlyElementParseError. The blank-line skip promised since 1.1.3 is now reachable: the stale pre-rewrite EOF sentinel in consume() fired on empty lines first and misreported them as 'early end-of-file'. --- CHANGELOG.md | 5 +++ plyfile.py | 22 ++++++++----- test/test_plyfile.py | 73 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a5236..f77d8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented here. ## [Unreleased] +### Fixed +- Reject negative list counts instead of silently reading the rest of + the file or raising a misleading "early end-of-file". +- Reject negative element counts with a `PlyParseError`. +- Skip empty header lines, as documented in 1.1.3. ## [1.1.5] - 2026-07-25 ### Fixed diff --git a/plyfile.py b/plyfile.py index c2a1e1d..55cf466 100644 --- a/plyfile.py +++ b/plyfile.py @@ -615,9 +615,10 @@ def _read_txt(self, stream): for prop in self.properties: try: self._data[prop.name][k] = prop._from_fields(fields) - except StopIteration: - raise PlyElementParseError("early end-of-line", - self, k, prop) + except StopIteration as exc: + raise PlyElementParseError( + exc.value if exc.value else "early end-of-line", + self, k, prop) except ValueError: raise PlyElementParseError("malformed input", self, k, prop) @@ -667,9 +668,10 @@ def _read_bin(self, stream, byte_order): try: self._data[prop.name][k] = \ prop._read_bin(stream, byte_order) - except StopIteration: - raise PlyElementParseError("early end-of-file", - self, k, prop) + except StopIteration as exc: + raise PlyElementParseError( + exc.value if exc.value else "early end-of-file", + self, k, prop) def _write_bin(self, stream, byte_order): """ @@ -944,6 +946,8 @@ def _from_fields(self, fields): (len_t, val_t) = self.list_dtype() n = int(_np.dtype(len_t).type(next(fields))) + if n < 0: + raise StopIteration("negative list length %d" % n) data = _np.loadtxt(list(_islice(fields, n)), val_t, ndmin=1) if len(data) < n: @@ -1145,8 +1149,6 @@ def consume(self, raw_line): Parse and internalize one line of input. """ self.lines += 1 - if not raw_line: - self._error("early end-of-file") line = raw_line.strip() if line == '': @@ -1215,6 +1217,8 @@ def parse_element(self, data): count = int(fields[1]) except ValueError: self._error("expected integer count") + if count < 0: + self._error("expected non-negative integer count") self.elements.append((name, [], count, [])) self._allowed = ['element', 'comment', 'property', 'end_header'] @@ -1415,6 +1419,8 @@ def _read_array(stream, dtype, n): StopIteration If `n` elements could not be read. """ + if n < 0: + raise StopIteration("negative array length %d" % int(n)) try: size = int(_np.dtype(dtype).itemsize) * int(n) return _np.frombuffer(stream.read(size), dtype) diff --git a/test/test_plyfile.py b/test/test_plyfile.py index cf15d46..f8dea80 100644 --- a/test/test_plyfile.py +++ b/test/test_plyfile.py @@ -704,7 +704,22 @@ def ply_list_a(fmt, n, data): "row 0: property 'a': early end-of-file"), (ply_abc('binary_little_endian', 2, b'\x01\x02\x03'), - "row 1: early end-of-file") + "row 1: early end-of-file"), + + (b'ply\nformat binary_little_endian 1.0\nelement test 1\n' + b'property list int8 int8 a\nend_header\n' + b'\xff\x01\x02\x03', + "row 0: property 'a': negative array length -1"), + + (b'ply\nformat binary_little_endian 1.0\nelement test 1\n' + b'property list int32 int a\nend_header\n' + b'\xfd\xff\xff\xff\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00', + "row 0: property 'a': negative array length -3"), + + (b'ply\nformat ascii 1.0\nelement test 1\n' + b'property list int8 int8 a\nend_header\n' + b'-3 1 2 3\n', + "row 0: property 'a': negative list length -3"), ] @@ -769,7 +784,9 @@ def test_invalid_property_names(): invalid_header_cases = [ (b'plyy\n', 1), (b'ply xxx\n', 1), - (b'ply\n\n', 2), + # A blank header line is skipped, so the error is reported at the + # following line, where the file actually ends. + (b'ply\n\n', 3), (b'ply\nformat\n', 2), (b'ply\nelement vertex 0\n', 2), (b'ply\nformat asciii 1.0\n', 2), @@ -788,7 +805,11 @@ def test_invalid_property_names(): (b'ply\nformat ascii 1.0\nelement vertex 0\n' b'property list ucharr int extra\n', 4), (b'ply\nformat ascii 1.0\nelement vertex 0\n' - b'property float x\nend_header xxx\n', 5) + b'property float x\nend_header xxx\n', 5), + (b'ply\nformat ascii 1.0\nelement vertex -1\nproperty float x\n' + b'end_header\n', 3), + (b'ply\nformat binary_little_endian 1.0\nelement vertex -1\n' + b'property float x\nend_header\n', 3), ] @@ -802,6 +823,52 @@ def test_header_parse_error(s, line): assert e.exc_val.line == line +def test_negative_element_count(): + with Raises(PlyHeaderParseError) as e: + PlyData.read(BytesIO(b'ply\nformat ascii 1.0\nelement vertex -1\n' + b'property float x\nend_header\n')) + assert e.exc_val.line == 3 + assert e.exc_val.message == "expected non-negative integer count" + + +def test_negative_list_count(tmpdir): + # A negative binary list count must be rejected on every stream type + # with the same message, not silently read as the rest of the file. + string = (b'ply\nformat binary_little_endian 1.0\nelement test 1\n' + b'property list int8 int8 a\nend_header\n' + b'\xff\x01\x02\x03') + filename = tmpdir.join('test.ply') + with filename.open('wb') as f: + f.write(string) + for s in (BytesIO(string), str(filename)): + with Raises(PlyElementParseError) as e: + PlyData.read(s) + assert str(e) == ("element 'test': row 0: property 'a': " + "negative array length -1") + + +def test_blank_header_lines(): + # Empty header lines are skipped (CHANGELOG 1.1.3). + string = (b'ply\n\nformat ascii 1.0\n\nelement vertex 1\n' + b'property float x\n\nend_header\n0\n') + ply = PlyData.read(BytesIO(string)) + assert ply['vertex'].count == 1 + assert ply['vertex']['x'][0] == 0.0 + + +def test_zero_list_count(): + string = (b'ply\nformat binary_little_endian 1.0\nelement test 1\n' + b'property list int8 int8 a\nend_header\n\x00') + ply = PlyData.read(BytesIO(string)) + assert len(ply['test']['a'][0]) == 0 + + +def test_zero_element_count(): + ply = PlyData.read(BytesIO(b'ply\nformat ascii 1.0\nelement vertex 0\n' + b'property float x\nend_header\n')) + assert ply['vertex'].count == 0 + + invalid_arrays = [ numpy.zeros((2, 2)), numpy.array([(0, (0, 0))],