/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to breezy/transport/http/response.py

  • Committer: Jelmer Vernooij
  • Date: 2020-02-07 02:14:30 UTC
  • mto: This revision was merged to the branch mainline in revision 7492.
  • Revision ID: jelmer@jelmer.uk-20200207021430-m49iq3x4x8xlib6x
Drop python2 support.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2006-2011 Canonical Ltd
 
2
#
 
3
# This program is free software; you can redistribute it and/or modify
 
4
# it under the terms of the GNU General Public License as published by
 
5
# the Free Software Foundation; either version 2 of the License, or
 
6
# (at your option) any later version.
 
7
#
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
#
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software
 
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
16
 
 
17
"""Handlers for HTTP Responses.
 
18
 
 
19
The purpose of these classes is to provide a uniform interface for clients
 
20
to standard HTTP responses, single range responses and multipart range
 
21
responses.
 
22
"""
 
23
 
 
24
from __future__ import absolute_import
 
25
 
 
26
import cgi
 
27
from io import BytesIO
 
28
import os
 
29
import http.client as http_client
 
30
import email.utils as email_utils
 
31
 
 
32
from ... import (
 
33
    errors,
 
34
    osutils,
 
35
    )
 
36
 
 
37
 
 
38
class ResponseFile(object):
 
39
    """A wrapper around the http socket containing the result of a GET request.
 
40
 
 
41
    Only read() and seek() (forward) are supported.
 
42
 
 
43
    """
 
44
 
 
45
    def __init__(self, path, infile):
 
46
        """Constructor.
 
47
 
 
48
        :param path: File url, for error reports.
 
49
 
 
50
        :param infile: File-like socket set at body start.
 
51
        """
 
52
        self._path = path
 
53
        self._file = infile
 
54
        self._pos = 0
 
55
 
 
56
    def close(self):
 
57
        """Close this file.
 
58
 
 
59
        Dummy implementation for consistency with the 'file' API.
 
60
        """
 
61
 
 
62
    def __enter__(self):
 
63
        return self
 
64
 
 
65
    def __exit__(self, exc_type, exc_val, exc_tb):
 
66
        return False  # propogate exceptions.
 
67
 
 
68
    def read(self, size=None):
 
69
        """Read size bytes from the current position in the file.
 
70
 
 
71
        :param size:  The number of bytes to read.  Leave unspecified or pass
 
72
            -1 to read to EOF.
 
73
        """
 
74
        data = self._file.read(size)
 
75
        self._pos += len(data)
 
76
        return data
 
77
 
 
78
    def readline(self):
 
79
        data = self._file.readline()
 
80
        self._pos += len(data)
 
81
        return data
 
82
 
 
83
    def __iter__(self):
 
84
        while True:
 
85
            line = self.readline()
 
86
            if not line:
 
87
                return
 
88
            yield line
 
89
 
 
90
    def tell(self):
 
91
        return self._pos
 
92
 
 
93
    def seek(self, offset, whence=os.SEEK_SET):
 
94
        if whence == os.SEEK_SET:
 
95
            if offset < self._pos:
 
96
                raise AssertionError(
 
97
                    "Can't seek backwards, pos: %s, offset: %s"
 
98
                    % (self._pos, offset))
 
99
            to_discard = offset - self._pos
 
100
        elif whence == os.SEEK_CUR:
 
101
            to_discard = offset
 
102
        else:
 
103
            raise AssertionError("Can't seek backwards")
 
104
        if to_discard:
 
105
            # Just discard the unwanted bytes
 
106
            self.read(to_discard)
 
107
 
 
108
# A RangeFile expects the following grammar (simplified to outline the
 
109
# assumptions we rely upon).
 
110
 
 
111
# file: single_range
 
112
#     | multiple_range
 
113
 
 
114
# single_range: content_range_header data
 
115
 
 
116
# multiple_range: boundary_header boundary (content_range_header data boundary)+
 
117
 
 
118
 
 
119
class RangeFile(ResponseFile):
 
120
    """File-like object that allow access to partial available data.
 
121
 
 
122
    All accesses should happen sequentially since the acquisition occurs during
 
123
    an http response reception (as sockets can't be seeked, we simulate the
 
124
    seek by just reading and discarding the data).
 
125
 
 
126
    The access pattern is defined by a set of ranges discovered as reading
 
127
    progress. Only one range is available at a given time, so all accesses
 
128
    should happen with monotonically increasing offsets.
 
129
    """
 
130
 
 
131
    # in _checked_read() below, we may have to discard several MB in the worst
 
132
    # case. To avoid buffering that much, we read and discard by chunks
 
133
    # instead. The underlying file is either a socket or a BytesIO, so reading
 
134
    # 8k chunks should be fine.
 
135
    _discarded_buf_size = 8192
 
136
 
 
137
    # maximum size of read requests -- used to avoid MemoryError issues in recv
 
138
    _max_read_size = 512 * 1024
 
139
 
 
140
    def __init__(self, path, infile):
 
141
        """Constructor.
 
142
 
 
143
        :param path: File url, for error reports.
 
144
 
 
145
        :param infile: File-like socket set at body start.
 
146
        """
 
147
        super(RangeFile, self).__init__(path, infile)
 
148
        self._boundary = None
 
149
        # When using multi parts response, this will be set with the headers
 
150
        # associated with the range currently read.
 
151
        self._headers = None
 
152
        # Default to the whole file of unspecified size
 
153
        self.set_range(0, -1)
 
154
 
 
155
    def set_range(self, start, size):
 
156
        """Change the range mapping"""
 
157
        self._start = start
 
158
        self._size = size
 
159
        # Set the new _pos since that's what we want to expose
 
160
        self._pos = self._start
 
161
 
 
162
    def set_boundary(self, boundary):
 
163
        """Define the boundary used in a multi parts message.
 
164
 
 
165
        The file should be at the beginning of the body, the first range
 
166
        definition is read and taken into account.
 
167
        """
 
168
        if not isinstance(boundary, bytes):
 
169
            raise TypeError(boundary)
 
170
        self._boundary = boundary
 
171
        # Decode the headers and setup the first range
 
172
        self.read_boundary()
 
173
        self.read_range_definition()
 
174
 
 
175
    def read_boundary(self):
 
176
        """Read the boundary headers defining a new range"""
 
177
        boundary_line = b'\r\n'
 
178
        while boundary_line == b'\r\n':
 
179
            # RFC2616 19.2 Additional CRLFs may precede the first boundary
 
180
            # string entity.
 
181
            # To be on the safe side we allow it before any boundary line
 
182
            boundary_line = self._file.readline()
 
183
 
 
184
        if boundary_line == b'':
 
185
            # A timeout in the proxy server caused the response to end early.
 
186
            # See launchpad bug 198646.
 
187
            raise errors.HttpBoundaryMissing(
 
188
                self._path,
 
189
                self._boundary)
 
190
 
 
191
        if boundary_line != b'--' + self._boundary + b'\r\n':
 
192
            # email_utils.unquote() incorrectly unquotes strings enclosed in <>
 
193
            # IIS 6 and 7 incorrectly wrap boundary strings in <>
 
194
            # together they make a beautiful bug, which we will be gracious
 
195
            # about here
 
196
            if (self._unquote_boundary(boundary_line) !=
 
197
                    b'--' + self._boundary + b'\r\n'):
 
198
                raise errors.InvalidHttpResponse(
 
199
                    self._path,
 
200
                    "Expected a boundary (%s) line, got '%s'"
 
201
                    % (self._boundary, boundary_line))
 
202
 
 
203
    def _unquote_boundary(self, b):
 
204
        return b[:2] + email_utils.unquote(b[2:-2].decode('ascii')).encode('ascii') + b[-2:]
 
205
 
 
206
    def read_range_definition(self):
 
207
        """Read a new range definition in a multi parts message.
 
208
 
 
209
        Parse the headers including the empty line following them so that we
 
210
        are ready to read the data itself.
 
211
        """
 
212
        self._headers = http_client.parse_headers(self._file)
 
213
        # Extract the range definition
 
214
        content_range = self._headers.get('content-range', None)
 
215
        if content_range is None:
 
216
            raise errors.InvalidHttpResponse(
 
217
                self._path,
 
218
                'Content-Range header missing in a multi-part response')
 
219
        self.set_range_from_header(content_range)
 
220
 
 
221
    def set_range_from_header(self, content_range):
 
222
        """Helper to set the new range from its description in the headers"""
 
223
        try:
 
224
            rtype, values = content_range.split()
 
225
        except ValueError:
 
226
            raise errors.InvalidHttpRange(self._path, content_range,
 
227
                                          'Malformed header')
 
228
        if rtype != 'bytes':
 
229
            raise errors.InvalidHttpRange(self._path, content_range,
 
230
                                          "Unsupported range type '%s'" % rtype)
 
231
        try:
 
232
            # We don't need total, but note that it may be either the file size
 
233
            # or '*' if the server can't or doesn't want to return the file
 
234
            # size.
 
235
            start_end, total = values.split('/')
 
236
            start, end = start_end.split('-')
 
237
            start = int(start)
 
238
            end = int(end)
 
239
        except ValueError:
 
240
            raise errors.InvalidHttpRange(self._path, content_range,
 
241
                                          'Invalid range values')
 
242
        size = end - start + 1
 
243
        if size <= 0:
 
244
            raise errors.InvalidHttpRange(self._path, content_range,
 
245
                                          'Invalid range, size <= 0')
 
246
        self.set_range(start, size)
 
247
 
 
248
    def _checked_read(self, size):
 
249
        """Read the file checking for short reads.
 
250
 
 
251
        The data read is discarded along the way.
 
252
        """
 
253
        pos = self._pos
 
254
        remaining = size
 
255
        while remaining > 0:
 
256
            data = self._file.read(min(remaining, self._discarded_buf_size))
 
257
            remaining -= len(data)
 
258
            if not data:
 
259
                raise errors.ShortReadvError(self._path, pos, size,
 
260
                                             size - remaining)
 
261
        self._pos += size
 
262
 
 
263
    def _seek_to_next_range(self):
 
264
        # We will cross range boundaries
 
265
        if self._boundary is None:
 
266
            # If we don't have a boundary, we can't find another range
 
267
            raise errors.InvalidRange(self._path, self._pos,
 
268
                                      "Range (%s, %s) exhausted"
 
269
                                      % (self._start, self._size))
 
270
        self.read_boundary()
 
271
        self.read_range_definition()
 
272
 
 
273
    def read(self, size=-1):
 
274
        """Read size bytes from the current position in the file.
 
275
 
 
276
        Reading across ranges is not supported. We rely on the underlying http
 
277
        client to clean the socket if we leave bytes unread. This may occur for
 
278
        the final boundary line of a multipart response or for any range
 
279
        request not entirely consumed by the client (due to offset coalescing)
 
280
 
 
281
        :param size:  The number of bytes to read.  Leave unspecified or pass
 
282
            -1 to read to EOF.
 
283
        """
 
284
        if (self._size > 0
 
285
                and self._pos == self._start + self._size):
 
286
            if size == 0:
 
287
                return b''
 
288
            else:
 
289
                self._seek_to_next_range()
 
290
        elif self._pos < self._start:
 
291
            raise errors.InvalidRange(
 
292
                self._path, self._pos,
 
293
                "Can't read %s bytes before range (%s, %s)"
 
294
                % (size, self._start, self._size))
 
295
        if self._size > 0:
 
296
            if size > 0 and self._pos + size > self._start + self._size:
 
297
                raise errors.InvalidRange(
 
298
                    self._path, self._pos,
 
299
                    "Can't read %s bytes across range (%s, %s)"
 
300
                    % (size, self._start, self._size))
 
301
 
 
302
        # read data from file
 
303
        buf = BytesIO()
 
304
        limited = size
 
305
        if self._size > 0:
 
306
            # Don't read past the range definition
 
307
            limited = self._start + self._size - self._pos
 
308
            if size >= 0:
 
309
                limited = min(limited, size)
 
310
        osutils.pumpfile(self._file, buf, limited, self._max_read_size)
 
311
        data = buf.getvalue()
 
312
 
 
313
        # Update _pos respecting the data effectively read
 
314
        self._pos += len(data)
 
315
        return data
 
316
 
 
317
    def seek(self, offset, whence=0):
 
318
        start_pos = self._pos
 
319
        if whence == 0:
 
320
            final_pos = offset
 
321
        elif whence == 1:
 
322
            final_pos = start_pos + offset
 
323
        elif whence == 2:
 
324
            if self._size > 0:
 
325
                final_pos = self._start + self._size + offset  # offset < 0
 
326
            else:
 
327
                raise errors.InvalidRange(
 
328
                    self._path, self._pos,
 
329
                    "RangeFile: can't seek from end while size is unknown")
 
330
        else:
 
331
            raise ValueError("Invalid value %s for whence." % whence)
 
332
 
 
333
        if final_pos < self._pos:
 
334
            # Can't seek backwards
 
335
            raise errors.InvalidRange(
 
336
                self._path, self._pos,
 
337
                'RangeFile: trying to seek backwards to %s' % final_pos)
 
338
 
 
339
        if self._size > 0:
 
340
            cur_limit = self._start + self._size
 
341
            while final_pos > cur_limit:
 
342
                # We will cross range boundaries
 
343
                remain = cur_limit - self._pos
 
344
                if remain > 0:
 
345
                    # Finish reading the current range
 
346
                    self._checked_read(remain)
 
347
                self._seek_to_next_range()
 
348
                cur_limit = self._start + self._size
 
349
 
 
350
        size = final_pos - self._pos
 
351
        if size > 0:  # size can be < 0 if we crossed a range boundary
 
352
            # We don't need the data, just read it and throw it away
 
353
            self._checked_read(size)
 
354
 
 
355
    def tell(self):
 
356
        return self._pos
 
357
 
 
358
 
 
359
def handle_response(url, code, getheader, data):
 
360
    """Interpret the code & headers and wrap the provided data in a RangeFile.
 
361
 
 
362
    This is a factory method which returns an appropriate RangeFile based on
 
363
    the code & headers it's given.
 
364
 
 
365
    :param url: The url being processed. Mostly for error reporting
 
366
    :param code: The integer HTTP response code
 
367
    :param getheader: Function for retrieving header
 
368
    :param data: A file-like object that can be read() to get the
 
369
                 requested data
 
370
    :return: A file-like object that can seek()+read() the
 
371
             ranges indicated by the headers.
 
372
    """
 
373
    if code == 200:
 
374
        # A whole file
 
375
        rfile = ResponseFile(url, data)
 
376
    elif code == 206:
 
377
        rfile = RangeFile(url, data)
 
378
        # When there is no content-type header we treat the response as
 
379
        # being of type 'application/octet-stream' as per RFC2616 section
 
380
        # 7.2.1.
 
381
        # Therefore it is obviously not multipart
 
382
        content_type = getheader('content-type', 'application/octet-stream')
 
383
        mimetype, options = cgi.parse_header(content_type)
 
384
        if mimetype == 'multipart/byteranges':
 
385
            rfile.set_boundary(options['boundary'].encode('ascii'))
 
386
        else:
 
387
            # A response to a range request, but not multipart
 
388
            content_range = getheader('content-range', None)
 
389
            if content_range is None:
 
390
                raise errors.InvalidHttpResponse(
 
391
                    url, 'Missing the Content-Range header in a 206 range response')
 
392
            rfile.set_range_from_header(content_range)
 
393
    else:
 
394
        raise errors.InvalidHttpResponse(url,
 
395
                                         'Unknown response code %s' % code)
 
396
 
 
397
    return rfile