##// END OF EJS Templates
safehasattr: pass attribute name as string instead of bytes...
marmoute -
r51475:b23b3ef3 default
parent child Browse files
Show More
@@ -1,663 +1,663 b''
1 1 # httppeer.py - HTTP repository proxy classes for mercurial
2 2 #
3 3 # Copyright 2005, 2006 Olivia Mackall <olivia@selenic.com>
4 4 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9
10 10 import errno
11 11 import io
12 12 import os
13 13 import socket
14 14 import struct
15 15
16 16 from concurrent import futures
17 17 from .i18n import _
18 18 from .pycompat import getattr
19 19 from . import (
20 20 bundle2,
21 21 error,
22 22 httpconnection,
23 23 pycompat,
24 24 statichttprepo,
25 25 url as urlmod,
26 26 util,
27 27 wireprotov1peer,
28 28 )
29 29 from .utils import urlutil
30 30
31 31 httplib = util.httplib
32 32 urlerr = util.urlerr
33 33 urlreq = util.urlreq
34 34
35 35
36 36 def encodevalueinheaders(value, header, limit):
37 37 """Encode a string value into multiple HTTP headers.
38 38
39 39 ``value`` will be encoded into 1 or more HTTP headers with the names
40 40 ``header-<N>`` where ``<N>`` is an integer starting at 1. Each header
41 41 name + value will be at most ``limit`` bytes long.
42 42
43 43 Returns an iterable of 2-tuples consisting of header names and
44 44 values as native strings.
45 45 """
46 46 # HTTP Headers are ASCII. Python 3 requires them to be unicodes,
47 47 # not bytes. This function always takes bytes in as arguments.
48 48 fmt = pycompat.strurl(header) + r'-%s'
49 49 # Note: it is *NOT* a bug that the last bit here is a bytestring
50 50 # and not a unicode: we're just getting the encoded length anyway,
51 51 # and using an r-string to make it portable between Python 2 and 3
52 52 # doesn't work because then the \r is a literal backslash-r
53 53 # instead of a carriage return.
54 54 valuelen = limit - len(fmt % '000') - len(b': \r\n')
55 55 result = []
56 56
57 57 n = 0
58 58 for i in range(0, len(value), valuelen):
59 59 n += 1
60 60 result.append((fmt % str(n), pycompat.strurl(value[i : i + valuelen])))
61 61
62 62 return result
63 63
64 64
65 65 class _multifile:
66 66 def __init__(self, *fileobjs):
67 67 for f in fileobjs:
68 68 if not util.safehasattr(f, 'length'):
69 69 raise ValueError(
70 70 b'_multifile only supports file objects that '
71 71 b'have a length but this one does not:',
72 72 type(f),
73 73 f,
74 74 )
75 75 self._fileobjs = fileobjs
76 76 self._index = 0
77 77
78 78 @property
79 79 def length(self):
80 80 return sum(f.length for f in self._fileobjs)
81 81
82 82 def read(self, amt=None):
83 83 if amt <= 0:
84 84 return b''.join(f.read() for f in self._fileobjs)
85 85 parts = []
86 86 while amt and self._index < len(self._fileobjs):
87 87 parts.append(self._fileobjs[self._index].read(amt))
88 88 got = len(parts[-1])
89 89 if got < amt:
90 90 self._index += 1
91 91 amt -= got
92 92 return b''.join(parts)
93 93
94 94 def seek(self, offset, whence=os.SEEK_SET):
95 95 if whence != os.SEEK_SET:
96 96 raise NotImplementedError(
97 97 b'_multifile does not support anything other'
98 98 b' than os.SEEK_SET for whence on seek()'
99 99 )
100 100 if offset != 0:
101 101 raise NotImplementedError(
102 102 b'_multifile only supports seeking to start, but that '
103 103 b'could be fixed if you need it'
104 104 )
105 105 for f in self._fileobjs:
106 106 f.seek(0)
107 107 self._index = 0
108 108
109 109
110 110 def makev1commandrequest(
111 111 ui,
112 112 requestbuilder,
113 113 caps,
114 114 capablefn,
115 115 repobaseurl,
116 116 cmd,
117 117 args,
118 118 remotehidden=False,
119 119 ):
120 120 """Make an HTTP request to run a command for a version 1 client.
121 121
122 122 ``caps`` is a set of known server capabilities. The value may be
123 123 None if capabilities are not yet known.
124 124
125 125 ``capablefn`` is a function to evaluate a capability.
126 126
127 127 ``cmd``, ``args``, and ``data`` define the command, its arguments, and
128 128 raw data to pass to it.
129 129 """
130 130 if cmd == b'pushkey':
131 131 args[b'data'] = b''
132 132 data = args.pop(b'data', None)
133 133 headers = args.pop(b'headers', {})
134 134
135 135 ui.debug(b"sending %s command\n" % cmd)
136 136 q = [(b'cmd', cmd)]
137 137 if remotehidden:
138 138 q.append(('access-hidden', '1'))
139 139 headersize = 0
140 140 # Important: don't use self.capable() here or else you end up
141 141 # with infinite recursion when trying to look up capabilities
142 142 # for the first time.
143 143 postargsok = caps is not None and b'httppostargs' in caps
144 144
145 145 # Send arguments via POST.
146 146 if postargsok and args:
147 147 strargs = urlreq.urlencode(sorted(args.items()))
148 148 if not data:
149 149 data = strargs
150 150 else:
151 151 if isinstance(data, bytes):
152 152 i = io.BytesIO(data)
153 153 i.length = len(data)
154 154 data = i
155 155 argsio = io.BytesIO(strargs)
156 156 argsio.length = len(strargs)
157 157 data = _multifile(argsio, data)
158 158 headers['X-HgArgs-Post'] = len(strargs)
159 159 elif args:
160 160 # Calling self.capable() can infinite loop if we are calling
161 161 # "capabilities". But that command should never accept wire
162 162 # protocol arguments. So this should never happen.
163 163 assert cmd != b'capabilities'
164 164 httpheader = capablefn(b'httpheader')
165 165 if httpheader:
166 166 headersize = int(httpheader.split(b',', 1)[0])
167 167
168 168 # Send arguments via HTTP headers.
169 169 if headersize > 0:
170 170 # The headers can typically carry more data than the URL.
171 171 encoded_args = urlreq.urlencode(sorted(args.items()))
172 172 for header, value in encodevalueinheaders(
173 173 encoded_args, b'X-HgArg', headersize
174 174 ):
175 175 headers[header] = value
176 176 # Send arguments via query string (Mercurial <1.9).
177 177 else:
178 178 q += sorted(args.items())
179 179
180 180 qs = b'?%s' % urlreq.urlencode(q)
181 181 cu = b"%s%s" % (repobaseurl, qs)
182 182 size = 0
183 if util.safehasattr(data, b'length'):
183 if util.safehasattr(data, 'length'):
184 184 size = data.length
185 185 elif data is not None:
186 186 size = len(data)
187 187 if data is not None and 'Content-Type' not in headers:
188 188 headers['Content-Type'] = 'application/mercurial-0.1'
189 189
190 190 # Tell the server we accept application/mercurial-0.2 and multiple
191 191 # compression formats if the server is capable of emitting those
192 192 # payloads.
193 193 # Note: Keep this set empty by default, as client advertisement of
194 194 # protocol parameters should only occur after the handshake.
195 195 protoparams = set()
196 196
197 197 mediatypes = set()
198 198 if caps is not None:
199 199 mt = capablefn(b'httpmediatype')
200 200 if mt:
201 201 protoparams.add(b'0.1')
202 202 mediatypes = set(mt.split(b','))
203 203
204 204 protoparams.add(b'partial-pull')
205 205
206 206 if b'0.2tx' in mediatypes:
207 207 protoparams.add(b'0.2')
208 208
209 209 if b'0.2tx' in mediatypes and capablefn(b'compression'):
210 210 # We /could/ compare supported compression formats and prune
211 211 # non-mutually supported or error if nothing is mutually supported.
212 212 # For now, send the full list to the server and have it error.
213 213 comps = [
214 214 e.wireprotosupport().name
215 215 for e in util.compengines.supportedwireengines(util.CLIENTROLE)
216 216 ]
217 217 protoparams.add(b'comp=%s' % b','.join(comps))
218 218
219 219 if protoparams:
220 220 protoheaders = encodevalueinheaders(
221 221 b' '.join(sorted(protoparams)), b'X-HgProto', headersize or 1024
222 222 )
223 223 for header, value in protoheaders:
224 224 headers[header] = value
225 225
226 226 varyheaders = []
227 227 for header in headers:
228 228 if header.lower().startswith('x-hg'):
229 229 varyheaders.append(header)
230 230
231 231 if varyheaders:
232 232 headers['Vary'] = ','.join(sorted(varyheaders))
233 233
234 234 req = requestbuilder(pycompat.strurl(cu), data, headers)
235 235
236 236 if data is not None:
237 237 ui.debug(b"sending %d bytes\n" % size)
238 238 req.add_unredirected_header('Content-Length', '%d' % size)
239 239
240 240 return req, cu, qs
241 241
242 242
243 243 def sendrequest(ui, opener, req):
244 244 """Send a prepared HTTP request.
245 245
246 246 Returns the response object.
247 247 """
248 248 dbg = ui.debug
249 249 if ui.debugflag and ui.configbool(b'devel', b'debug.peer-request'):
250 250 line = b'devel-peer-request: %s\n'
251 251 dbg(
252 252 line
253 253 % b'%s %s'
254 254 % (
255 255 pycompat.bytesurl(req.get_method()),
256 256 pycompat.bytesurl(req.get_full_url()),
257 257 )
258 258 )
259 259 hgargssize = None
260 260
261 261 for header, value in sorted(req.header_items()):
262 262 header = pycompat.bytesurl(header)
263 263 value = pycompat.bytesurl(value)
264 264 if header.startswith(b'X-hgarg-'):
265 265 if hgargssize is None:
266 266 hgargssize = 0
267 267 hgargssize += len(value)
268 268 else:
269 269 dbg(line % b' %s %s' % (header, value))
270 270
271 271 if hgargssize is not None:
272 272 dbg(
273 273 line
274 274 % b' %d bytes of commands arguments in headers'
275 275 % hgargssize
276 276 )
277 277 data = req.data
278 278 if data is not None:
279 279 length = getattr(data, 'length', None)
280 280 if length is None:
281 281 length = len(data)
282 282 dbg(line % b' %d bytes of data' % length)
283 283
284 284 start = util.timer()
285 285
286 286 res = None
287 287 try:
288 288 res = opener.open(req)
289 289 except urlerr.httperror as inst:
290 290 if inst.code == 401:
291 291 raise error.Abort(_(b'authorization failed'))
292 292 raise
293 293 except httplib.HTTPException as inst:
294 294 ui.debug(
295 295 b'http error requesting %s\n'
296 296 % urlutil.hidepassword(req.get_full_url())
297 297 )
298 298 ui.traceback()
299 299 raise IOError(None, inst)
300 300 finally:
301 301 if ui.debugflag and ui.configbool(b'devel', b'debug.peer-request'):
302 302 code = res.code if res else -1
303 303 dbg(
304 304 line
305 305 % b' finished in %.4f seconds (%d)'
306 306 % (util.timer() - start, code)
307 307 )
308 308
309 309 # Insert error handlers for common I/O failures.
310 310 urlmod.wrapresponse(res)
311 311
312 312 return res
313 313
314 314
315 315 class RedirectedRepoError(error.RepoError):
316 316 def __init__(self, msg, respurl):
317 317 super(RedirectedRepoError, self).__init__(msg)
318 318 self.respurl = respurl
319 319
320 320
321 321 def parsev1commandresponse(ui, baseurl, requrl, qs, resp, compressible):
322 322 # record the url we got redirected to
323 323 redirected = False
324 324 respurl = pycompat.bytesurl(resp.geturl())
325 325 if respurl.endswith(qs):
326 326 respurl = respurl[: -len(qs)]
327 327 qsdropped = False
328 328 else:
329 329 qsdropped = True
330 330
331 331 if baseurl.rstrip(b'/') != respurl.rstrip(b'/'):
332 332 redirected = True
333 333 if not ui.quiet:
334 334 ui.warn(_(b'real URL is %s\n') % respurl)
335 335
336 336 try:
337 337 proto = pycompat.bytesurl(resp.getheader('content-type', ''))
338 338 except AttributeError:
339 339 proto = pycompat.bytesurl(resp.headers.get('content-type', ''))
340 340
341 341 safeurl = urlutil.hidepassword(baseurl)
342 342 if proto.startswith(b'application/hg-error'):
343 343 raise error.OutOfBandError(resp.read())
344 344
345 345 # Pre 1.0 versions of Mercurial used text/plain and
346 346 # application/hg-changegroup. We don't support such old servers.
347 347 if not proto.startswith(b'application/mercurial-'):
348 348 ui.debug(b"requested URL: '%s'\n" % urlutil.hidepassword(requrl))
349 349 msg = _(
350 350 b"'%s' does not appear to be an hg repository:\n"
351 351 b"---%%<--- (%s)\n%s\n---%%<---\n"
352 352 ) % (safeurl, proto or b'no content-type', resp.read(1024))
353 353
354 354 # Some servers may strip the query string from the redirect. We
355 355 # raise a special error type so callers can react to this specially.
356 356 if redirected and qsdropped:
357 357 raise RedirectedRepoError(msg, respurl)
358 358 else:
359 359 raise error.RepoError(msg)
360 360
361 361 try:
362 362 subtype = proto.split(b'-', 1)[1]
363 363
364 364 version_info = tuple([int(n) for n in subtype.split(b'.')])
365 365 except ValueError:
366 366 raise error.RepoError(
367 367 _(b"'%s' sent a broken Content-Type header (%s)") % (safeurl, proto)
368 368 )
369 369
370 370 # TODO consider switching to a decompression reader that uses
371 371 # generators.
372 372 if version_info == (0, 1):
373 373 if compressible:
374 374 resp = util.compengines[b'zlib'].decompressorreader(resp)
375 375
376 376 elif version_info == (0, 2):
377 377 # application/mercurial-0.2 always identifies the compression
378 378 # engine in the payload header.
379 379 elen = struct.unpack(b'B', util.readexactly(resp, 1))[0]
380 380 ename = util.readexactly(resp, elen)
381 381 engine = util.compengines.forwiretype(ename)
382 382
383 383 resp = engine.decompressorreader(resp)
384 384 else:
385 385 raise error.RepoError(
386 386 _(b"'%s' uses newer protocol %s") % (safeurl, subtype)
387 387 )
388 388
389 389 return respurl, proto, resp
390 390
391 391
392 392 class httppeer(wireprotov1peer.wirepeer):
393 393 def __init__(
394 394 self, ui, path, url, opener, requestbuilder, caps, remotehidden=False
395 395 ):
396 396 super().__init__(ui, path=path, remotehidden=remotehidden)
397 397 self._url = url
398 398 self._caps = caps
399 399 self.limitedarguments = caps is not None and b'httppostargs' not in caps
400 400 self._urlopener = opener
401 401 self._requestbuilder = requestbuilder
402 402 self._remotehidden = remotehidden
403 403
404 404 def __del__(self):
405 405 for h in self._urlopener.handlers:
406 406 h.close()
407 407 getattr(h, "close_all", lambda: None)()
408 408
409 409 # Begin of ipeerconnection interface.
410 410
411 411 def url(self):
412 412 return self.path.loc
413 413
414 414 def local(self):
415 415 return None
416 416
417 417 def canpush(self):
418 418 return True
419 419
420 420 def close(self):
421 421 try:
422 422 reqs, sent, recv = (
423 423 self._urlopener.requestscount,
424 424 self._urlopener.sentbytescount,
425 425 self._urlopener.receivedbytescount,
426 426 )
427 427 except AttributeError:
428 428 return
429 429 self.ui.note(
430 430 _(
431 431 b'(sent %d HTTP requests and %d bytes; '
432 432 b'received %d bytes in responses)\n'
433 433 )
434 434 % (reqs, sent, recv)
435 435 )
436 436
437 437 # End of ipeerconnection interface.
438 438
439 439 # Begin of ipeercommands interface.
440 440
441 441 def capabilities(self):
442 442 return self._caps
443 443
444 444 # End of ipeercommands interface.
445 445
446 446 def _callstream(self, cmd, _compressible=False, **args):
447 447 args = pycompat.byteskwargs(args)
448 448
449 449 req, cu, qs = makev1commandrequest(
450 450 self.ui,
451 451 self._requestbuilder,
452 452 self._caps,
453 453 self.capable,
454 454 self._url,
455 455 cmd,
456 456 args,
457 457 self._remotehidden,
458 458 )
459 459
460 460 resp = sendrequest(self.ui, self._urlopener, req)
461 461
462 462 self._url, ct, resp = parsev1commandresponse(
463 463 self.ui, self._url, cu, qs, resp, _compressible
464 464 )
465 465
466 466 return resp
467 467
468 468 def _call(self, cmd, **args):
469 469 fp = self._callstream(cmd, **args)
470 470 try:
471 471 return fp.read()
472 472 finally:
473 473 # if using keepalive, allow connection to be reused
474 474 fp.close()
475 475
476 476 def _callpush(self, cmd, cg, **args):
477 477 # have to stream bundle to a temp file because we do not have
478 478 # http 1.1 chunked transfer.
479 479
480 480 types = self.capable(b'unbundle')
481 481 try:
482 482 types = types.split(b',')
483 483 except AttributeError:
484 484 # servers older than d1b16a746db6 will send 'unbundle' as a
485 485 # boolean capability. They only support headerless/uncompressed
486 486 # bundles.
487 487 types = [b""]
488 488 for x in types:
489 489 if x in bundle2.bundletypes:
490 490 type = x
491 491 break
492 492
493 493 tempname = bundle2.writebundle(self.ui, cg, None, type)
494 494 fp = httpconnection.httpsendfile(self.ui, tempname, b"rb")
495 495 headers = {'Content-Type': 'application/mercurial-0.1'}
496 496
497 497 try:
498 498 r = self._call(cmd, data=fp, headers=headers, **args)
499 499 vals = r.split(b'\n', 1)
500 500 if len(vals) < 2:
501 501 raise error.ResponseError(_(b"unexpected response:"), r)
502 502 return vals
503 503 except urlerr.httperror:
504 504 # Catch and re-raise these so we don't try and treat them
505 505 # like generic socket errors. They lack any values in
506 506 # .args on Python 3 which breaks our socket.error block.
507 507 raise
508 508 except socket.error as err:
509 509 if err.args[0] in (errno.ECONNRESET, errno.EPIPE):
510 510 raise error.Abort(_(b'push failed: %s') % err.args[1])
511 511 raise error.Abort(err.args[1])
512 512 finally:
513 513 fp.close()
514 514 os.unlink(tempname)
515 515
516 516 def _calltwowaystream(self, cmd, fp, **args):
517 517 filename = None
518 518 try:
519 519 # dump bundle to disk
520 520 fd, filename = pycompat.mkstemp(prefix=b"hg-bundle-", suffix=b".hg")
521 521 with os.fdopen(fd, "wb") as fh:
522 522 d = fp.read(4096)
523 523 while d:
524 524 fh.write(d)
525 525 d = fp.read(4096)
526 526 # start http push
527 527 with httpconnection.httpsendfile(self.ui, filename, b"rb") as fp_:
528 528 headers = {'Content-Type': 'application/mercurial-0.1'}
529 529 return self._callstream(cmd, data=fp_, headers=headers, **args)
530 530 finally:
531 531 if filename is not None:
532 532 os.unlink(filename)
533 533
534 534 def _callcompressable(self, cmd, **args):
535 535 return self._callstream(cmd, _compressible=True, **args)
536 536
537 537 def _abort(self, exception):
538 538 raise exception
539 539
540 540
541 541 class queuedcommandfuture(futures.Future):
542 542 """Wraps result() on command futures to trigger submission on call."""
543 543
544 544 def result(self, timeout=None):
545 545 if self.done():
546 546 return futures.Future.result(self, timeout)
547 547
548 548 self._peerexecutor.sendcommands()
549 549
550 550 # sendcommands() will restore the original __class__ and self.result
551 551 # will resolve to Future.result.
552 552 return self.result(timeout)
553 553
554 554
555 555 def performhandshake(ui, url, opener, requestbuilder):
556 556 # The handshake is a request to the capabilities command.
557 557
558 558 caps = None
559 559
560 560 def capable(x):
561 561 raise error.ProgrammingError(b'should not be called')
562 562
563 563 args = {}
564 564
565 565 req, requrl, qs = makev1commandrequest(
566 566 ui, requestbuilder, caps, capable, url, b'capabilities', args
567 567 )
568 568 resp = sendrequest(ui, opener, req)
569 569
570 570 # The server may redirect us to the repo root, stripping the
571 571 # ?cmd=capabilities query string from the URL. The server would likely
572 572 # return HTML in this case and ``parsev1commandresponse()`` would raise.
573 573 # We catch this special case and re-issue the capabilities request against
574 574 # the new URL.
575 575 #
576 576 # We should ideally not do this, as a redirect that drops the query
577 577 # string from the URL is arguably a server bug. (Garbage in, garbage out).
578 578 # However, Mercurial clients for several years appeared to handle this
579 579 # issue without behavior degradation. And according to issue 5860, it may
580 580 # be a longstanding bug in some server implementations. So we allow a
581 581 # redirect that drops the query string to "just work."
582 582 try:
583 583 respurl, ct, resp = parsev1commandresponse(
584 584 ui, url, requrl, qs, resp, compressible=False
585 585 )
586 586 except RedirectedRepoError as e:
587 587 req, requrl, qs = makev1commandrequest(
588 588 ui, requestbuilder, caps, capable, e.respurl, b'capabilities', args
589 589 )
590 590 resp = sendrequest(ui, opener, req)
591 591 respurl, ct, resp = parsev1commandresponse(
592 592 ui, url, requrl, qs, resp, compressible=False
593 593 )
594 594
595 595 try:
596 596 rawdata = resp.read()
597 597 finally:
598 598 resp.close()
599 599
600 600 if not ct.startswith(b'application/mercurial-'):
601 601 raise error.ProgrammingError(b'unexpected content-type: %s' % ct)
602 602
603 603 info = {b'v1capabilities': set(rawdata.split())}
604 604
605 605 return respurl, info
606 606
607 607
608 608 def _make_peer(
609 609 ui, path, opener=None, requestbuilder=urlreq.request, remotehidden=False
610 610 ):
611 611 """Construct an appropriate HTTP peer instance.
612 612
613 613 ``opener`` is an ``url.opener`` that should be used to establish
614 614 connections, perform HTTP requests.
615 615
616 616 ``requestbuilder`` is the type used for constructing HTTP requests.
617 617 It exists as an argument so extensions can override the default.
618 618 """
619 619 if path.url.query or path.url.fragment:
620 620 msg = _(b'unsupported URL component: "%s"')
621 621 msg %= path.url.query or path.url.fragment
622 622 raise error.Abort(msg)
623 623
624 624 # urllib cannot handle URLs with embedded user or passwd.
625 625 url, authinfo = path.url.authinfo()
626 626 ui.debug(b'using %s\n' % url)
627 627
628 628 opener = opener or urlmod.opener(ui, authinfo)
629 629
630 630 respurl, info = performhandshake(ui, url, opener, requestbuilder)
631 631
632 632 return httppeer(
633 633 ui,
634 634 path,
635 635 respurl,
636 636 opener,
637 637 requestbuilder,
638 638 info[b'v1capabilities'],
639 639 remotehidden=remotehidden,
640 640 )
641 641
642 642
643 643 def make_peer(
644 644 ui, path, create, intents=None, createopts=None, remotehidden=False
645 645 ):
646 646 if create:
647 647 raise error.Abort(_(b'cannot create new http repository'))
648 648 try:
649 649 if path.url.scheme == b'https' and not urlmod.has_https:
650 650 raise error.Abort(
651 651 _(b'Python support for SSL and HTTPS is not installed')
652 652 )
653 653
654 654 inst = _make_peer(ui, path, remotehidden=remotehidden)
655 655
656 656 return inst
657 657 except error.RepoError as httpexception:
658 658 try:
659 659 r = statichttprepo.make_peer(ui, b"static-" + path.loc, create)
660 660 ui.note(_(b'(falling back to static-http)\n'))
661 661 return r
662 662 except error.RepoError:
663 663 raise httpexception # use the original http RepoError instead
General Comments 0
You need to be logged in to leave comments. Login now