Skip to content

Commit 12a8efe

Browse files
Gares95gtsystem
authored andcommitted
Bound the buffered read to the range the server declared
PartialBuffer represents a declared byte range of a remote file, but on the non-stream path it called buffer.read() with no argument, consuming whatever the server chose to send before the declared size was consulted. How much got buffered was decided by the server rather than by the range requested: a client asking for 100 bytes buffered 20 MB when the server streamed that much, which defeats the purpose of fetching ranges at all. Read at most `size` bytes instead, in a loop that tolerates short reads. A single read() call is not enough: a socket-backed response can return fewer bytes than requested while more are still coming, so reading once would silently truncate. The data is written straight into the result buffer so no intermediate copy of the whole range is held. The existing tests all use BytesIO, which never short-reads, so these cases need their own tests. RemoteFetcher.fetch also turned the server's Content-Range straight into that size without checking it. A header whose end precedes its start, such as "bytes 100-50/1000", produced a negative size, and PartialBuffer.read(0) then computed a negative length, which for a file object means read everything. Malformed values such as "bytes abc-def/1000", "bytes */1000" or an empty header raised a bare ValueError out of the library. Both are now RemoteZipError. Adds tests that a server sending more than it declared does not enlarge the buffer, that a server sending less does not hang or raise, that short reads are handled without truncation, and that invalid or malformed Content-Range values are rejected.
1 parent 87220b5 commit 12a8efe

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

‎remotezip.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,30 @@ class PartialBuffer:
3030
however, any attempt to read data outside the partial data is going to fail
3131
with OutOfBound error.
3232
"""
33+
@staticmethod
34+
def _read_up_to(buffer, size):
35+
"""Read at most `size` bytes into a new buffer.
36+
37+
A single read() is not enough: a socket-backed response can return
38+
fewer bytes than requested while more are still coming. Data is written
39+
straight into the result so that no intermediate copy of the whole
40+
range is held.
41+
"""
42+
result = io.BytesIO()
43+
remaining = size
44+
while remaining > 0:
45+
chunk = buffer.read(remaining)
46+
if not chunk:
47+
break
48+
result.write(chunk)
49+
remaining -= len(chunk)
50+
result.seek(0)
51+
return result
52+
3353
def __init__(self, buffer, offset, size, stream):
34-
self.buffer = buffer if stream else io.BytesIO(buffer.read())
54+
# Read at most `size` bytes: the declared range is what this buffer
55+
# represents, and a server may send more than it announced.
56+
self.buffer = buffer if stream else self._read_up_to(buffer, size)
3557
self._offset = offset
3658
self._size = size
3759
self._position = offset
@@ -223,7 +245,14 @@ def fetch(self, data_range, stream=False):
223245
kwargs = self.prepare_request(data_range)
224246
try:
225247
res, range_header = self._request(kwargs)
226-
range_min, range_max = self.parse_range_header(range_header)
248+
try:
249+
range_min, range_max = self.parse_range_header(range_header)
250+
except ValueError:
251+
raise RemoteZipError(
252+
"Malformed Content-Range returned by the server: %s" % range_header)
253+
if range_max is None or range_max < range_min:
254+
raise RemoteZipError(
255+
"Invalid Content-Range returned by the server: %s" % range_header)
227256
return PartialBuffer(res, range_min, range_max - range_min + 1, stream)
228257
except IOError as e:
229258
raise RemoteIOError(str(e))

‎test_remotezip.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,36 @@ def fetch(self, data_range, stream=False):
6565

6666

6767
class TestPartialBuffer(unittest.TestCase):
68+
def test_handles_short_reads_from_the_stream(self):
69+
"""A socket-backed response may return less than asked for per read."""
70+
class ShortReader(io.RawIOBase):
71+
def __init__(self, data, chunk):
72+
self._b = io.BytesIO(data)
73+
self._chunk = chunk
74+
75+
def read(self, n=-1):
76+
if n is None or n < 0:
77+
return self._b.read()
78+
return self._b.read(min(n, self._chunk))
79+
80+
data = b'z' * 1000
81+
for chunk in (1000, 512, 100, 1):
82+
pb = rz.PartialBuffer(ShortReader(data, chunk), 0, len(data), stream=False)
83+
self.assertEqual(pb.read(0), data)
84+
85+
def test_handles_a_server_sending_less_than_declared(self):
86+
"""A truncated response must not hang or raise; it yields what arrived."""
87+
pb = rz.PartialBuffer(io.BytesIO(b'z' * 40), 0, 1000, stream=False)
88+
self.assertEqual(pb.read(0), b'z' * 40)
89+
90+
def test_does_not_buffer_more_than_declared_size(self):
91+
"""A server sending more than it declared must not enlarge the buffer."""
92+
oversized = io.BytesIO(b'x' * 10000)
93+
pb = rz.PartialBuffer(oversized, 0, 100, stream=False)
94+
self.assertEqual(len(pb.read(0)), 100)
95+
# the rest of the response was never pulled into memory
96+
self.assertEqual(oversized.tell(), 100)
97+
6898
def setUp(self):
6999
if not hasattr(self, 'assertRaisesRegex'):
70100
self.assertRaisesRegex = self.assertRaisesRegexp
@@ -206,6 +236,33 @@ def test_build_range_header(self):
206236
header = rz.RemoteFetcher.build_range_header(-123, None)
207237
self.assertEqual(header, 'bytes=-123')
208238

239+
def test_fetch_rejects_invalid_content_range(self):
240+
"""A server must not be able to declare a range that ends before it starts."""
241+
class Fetcher(rz.RemoteFetcher):
242+
def __init__(self, header):
243+
super(Fetcher, self).__init__('http://test.com/file.zip')
244+
self.header = header
245+
246+
def _request(self, kwargs):
247+
return io.BytesIO(b'x' * 100), self.header
248+
249+
with self.assertRaises(rz.RemoteZipError):
250+
Fetcher('bytes 100-50/1000').fetch((0, 99))
251+
252+
with self.assertRaises(rz.RemoteZipError):
253+
Fetcher('bytes -500/1000').fetch((0, 99))
254+
255+
# a malformed header must not leak a bare ValueError to the caller.
256+
# 'bytes */1000' is the RFC 7233 unsatisfied-range form, so this is not
257+
# only about hostile input.
258+
for bad in ('bytes abc-def/1000', 'bytes -500-100/1000', 'bytes /1000',
259+
'bytes */1000', ''):
260+
with self.assertRaises(rz.RemoteZipError):
261+
Fetcher(bad).fetch((0, 99))
262+
263+
# an unknown total length is legitimate and must still be accepted
264+
Fetcher('bytes 0-99/*').fetch((0, 99))
265+
209266
def test_parse_range_header(self):
210267
range_min, range_max = rz.RemoteFetcher.parse_range_header('bytes 0-11/12')
211268
self.assertEqual(range_min, 0)

0 commit comments

Comments
 (0)