##// END OF EJS Templates
sshpeer: defer pipe buffering and stderr sidechannel binding...
Gregory Szorc -
r36388:11ba1a96 default
parent child Browse files
Show More
@@ -1,557 +1,566 b''
1 1 # sshpeer.py - ssh repository proxy class for mercurial
2 2 #
3 3 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from __future__ import absolute_import
9 9
10 10 import re
11 11 import uuid
12 12
13 13 from .i18n import _
14 14 from . import (
15 15 error,
16 16 pycompat,
17 17 util,
18 18 wireproto,
19 19 wireprotoserver,
20 20 )
21 21
22 22 def _serverquote(s):
23 23 """quote a string for the remote shell ... which we assume is sh"""
24 24 if not s:
25 25 return s
26 26 if re.match('[a-zA-Z0-9@%_+=:,./-]*$', s):
27 27 return s
28 28 return "'%s'" % s.replace("'", "'\\''")
29 29
30 30 def _forwardoutput(ui, pipe):
31 31 """display all data currently available on pipe as remote output.
32 32
33 33 This is non blocking."""
34 34 s = util.readpipe(pipe)
35 35 if s:
36 36 for l in s.splitlines():
37 37 ui.status(_("remote: "), l, '\n')
38 38
39 39 class doublepipe(object):
40 40 """Operate a side-channel pipe in addition of a main one
41 41
42 42 The side-channel pipe contains server output to be forwarded to the user
43 43 input. The double pipe will behave as the "main" pipe, but will ensure the
44 44 content of the "side" pipe is properly processed while we wait for blocking
45 45 call on the "main" pipe.
46 46
47 47 If large amounts of data are read from "main", the forward will cease after
48 48 the first bytes start to appear. This simplifies the implementation
49 49 without affecting actual output of sshpeer too much as we rarely issue
50 50 large read for data not yet emitted by the server.
51 51
52 52 The main pipe is expected to be a 'bufferedinputpipe' from the util module
53 53 that handle all the os specific bits. This class lives in this module
54 54 because it focus on behavior specific to the ssh protocol."""
55 55
56 56 def __init__(self, ui, main, side):
57 57 self._ui = ui
58 58 self._main = main
59 59 self._side = side
60 60
61 61 def _wait(self):
62 62 """wait until some data are available on main or side
63 63
64 64 return a pair of boolean (ismainready, issideready)
65 65
66 66 (This will only wait for data if the setup is supported by `util.poll`)
67 67 """
68 68 if (isinstance(self._main, util.bufferedinputpipe) and
69 69 self._main.hasbuffer):
70 70 # Main has data. Assume side is worth poking at.
71 71 return True, True
72 72
73 73 fds = [self._main.fileno(), self._side.fileno()]
74 74 try:
75 75 act = util.poll(fds)
76 76 except NotImplementedError:
77 77 # non supported yet case, assume all have data.
78 78 act = fds
79 79 return (self._main.fileno() in act, self._side.fileno() in act)
80 80
81 81 def write(self, data):
82 82 return self._call('write', data)
83 83
84 84 def read(self, size):
85 85 r = self._call('read', size)
86 86 if size != 0 and not r:
87 87 # We've observed a condition that indicates the
88 88 # stdout closed unexpectedly. Check stderr one
89 89 # more time and snag anything that's there before
90 90 # letting anyone know the main part of the pipe
91 91 # closed prematurely.
92 92 _forwardoutput(self._ui, self._side)
93 93 return r
94 94
95 95 def readline(self):
96 96 return self._call('readline')
97 97
98 98 def _call(self, methname, data=None):
99 99 """call <methname> on "main", forward output of "side" while blocking
100 100 """
101 101 # data can be '' or 0
102 102 if (data is not None and not data) or self._main.closed:
103 103 _forwardoutput(self._ui, self._side)
104 104 return ''
105 105 while True:
106 106 mainready, sideready = self._wait()
107 107 if sideready:
108 108 _forwardoutput(self._ui, self._side)
109 109 if mainready:
110 110 meth = getattr(self._main, methname)
111 111 if data is None:
112 112 return meth()
113 113 else:
114 114 return meth(data)
115 115
116 116 def close(self):
117 117 return self._main.close()
118 118
119 119 def flush(self):
120 120 return self._main.flush()
121 121
122 122 def _cleanuppipes(ui, pipei, pipeo, pipee):
123 123 """Clean up pipes used by an SSH connection."""
124 124 if pipeo:
125 125 pipeo.close()
126 126 if pipei:
127 127 pipei.close()
128 128
129 129 if pipee:
130 130 # Try to read from the err descriptor until EOF.
131 131 try:
132 132 for l in pipee:
133 133 ui.status(_('remote: '), l)
134 134 except (IOError, ValueError):
135 135 pass
136 136
137 137 pipee.close()
138 138
139 139 def _makeconnection(ui, sshcmd, args, remotecmd, path, sshenv=None):
140 140 """Create an SSH connection to a server.
141 141
142 142 Returns a tuple of (process, stdin, stdout, stderr) for the
143 143 spawned process.
144 144 """
145 145 cmd = '%s %s %s' % (
146 146 sshcmd,
147 147 args,
148 148 util.shellquote('%s -R %s serve --stdio' % (
149 149 _serverquote(remotecmd), _serverquote(path))))
150 150
151 151 ui.debug('running %s\n' % cmd)
152 152 cmd = util.quotecommand(cmd)
153 153
154 154 # no buffer allow the use of 'select'
155 155 # feel free to remove buffering and select usage when we ultimately
156 156 # move to threading.
157 157 stdin, stdout, stderr, proc = util.popen4(cmd, bufsize=0, env=sshenv)
158 158
159 stdout = doublepipe(ui, util.bufferedinputpipe(stdout), stderr)
160 stdin = doublepipe(ui, stdin, stderr)
161
162 159 return proc, stdin, stdout, stderr
163 160
164 161 def _performhandshake(ui, stdin, stdout, stderr):
165 162 def badresponse():
163 # Flush any output on stderr.
164 _forwardoutput(ui, stderr)
165
166 166 msg = _('no suitable response from remote hg')
167 167 hint = ui.config('ui', 'ssherrorhint')
168 168 raise error.RepoError(msg, hint=hint)
169 169
170 170 # The handshake consists of sending wire protocol commands in reverse
171 171 # order of protocol implementation and then sniffing for a response
172 172 # to one of them.
173 173 #
174 174 # Those commands (from oldest to newest) are:
175 175 #
176 176 # ``between``
177 177 # Asks for the set of revisions between a pair of revisions. Command
178 178 # present in all Mercurial server implementations.
179 179 #
180 180 # ``hello``
181 181 # Instructs the server to advertise its capabilities. Introduced in
182 182 # Mercurial 0.9.1.
183 183 #
184 184 # ``upgrade``
185 185 # Requests upgrade from default transport protocol version 1 to
186 186 # a newer version. Introduced in Mercurial 4.6 as an experimental
187 187 # feature.
188 188 #
189 189 # The ``between`` command is issued with a request for the null
190 190 # range. If the remote is a Mercurial server, this request will
191 191 # generate a specific response: ``1\n\n``. This represents the
192 192 # wire protocol encoded value for ``\n``. We look for ``1\n\n``
193 193 # in the output stream and know this is the response to ``between``
194 194 # and we're at the end of our handshake reply.
195 195 #
196 196 # The response to the ``hello`` command will be a line with the
197 197 # length of the value returned by that command followed by that
198 198 # value. If the server doesn't support ``hello`` (which should be
199 199 # rare), that line will be ``0\n``. Otherwise, the value will contain
200 200 # RFC 822 like lines. Of these, the ``capabilities:`` line contains
201 201 # the capabilities of the server.
202 202 #
203 203 # The ``upgrade`` command isn't really a command in the traditional
204 204 # sense of version 1 of the transport because it isn't using the
205 205 # proper mechanism for formatting insteads: instead, it just encodes
206 206 # arguments on the line, delimited by spaces.
207 207 #
208 208 # The ``upgrade`` line looks like ``upgrade <token> <capabilities>``.
209 209 # If the server doesn't support protocol upgrades, it will reply to
210 210 # this line with ``0\n``. Otherwise, it emits an
211 211 # ``upgraded <token> <protocol>`` line to both stdout and stderr.
212 212 # Content immediately following this line describes additional
213 213 # protocol and server state.
214 214 #
215 215 # In addition to the responses to our command requests, the server
216 216 # may emit "banner" output on stdout. SSH servers are allowed to
217 217 # print messages to stdout on login. Issuing commands on connection
218 218 # allows us to flush this banner output from the server by scanning
219 219 # for output to our well-known ``between`` command. Of course, if
220 220 # the banner contains ``1\n\n``, this will throw off our detection.
221 221
222 222 requestlog = ui.configbool('devel', 'debug.peer-request')
223 223
224 224 # Generate a random token to help identify responses to version 2
225 225 # upgrade request.
226 226 token = pycompat.sysbytes(str(uuid.uuid4()))
227 227 upgradecaps = [
228 228 ('proto', wireprotoserver.SSHV2),
229 229 ]
230 230 upgradecaps = util.urlreq.urlencode(upgradecaps)
231 231
232 232 try:
233 233 pairsarg = '%s-%s' % ('0' * 40, '0' * 40)
234 234 handshake = [
235 235 'hello\n',
236 236 'between\n',
237 237 'pairs %d\n' % len(pairsarg),
238 238 pairsarg,
239 239 ]
240 240
241 241 # Request upgrade to version 2 if configured.
242 242 if ui.configbool('experimental', 'sshpeer.advertise-v2'):
243 243 ui.debug('sending upgrade request: %s %s\n' % (token, upgradecaps))
244 244 handshake.insert(0, 'upgrade %s %s\n' % (token, upgradecaps))
245 245
246 246 if requestlog:
247 247 ui.debug('devel-peer-request: hello\n')
248 248 ui.debug('sending hello command\n')
249 249 if requestlog:
250 250 ui.debug('devel-peer-request: between\n')
251 251 ui.debug('devel-peer-request: pairs: %d bytes\n' % len(pairsarg))
252 252 ui.debug('sending between command\n')
253 253
254 254 stdin.write(''.join(handshake))
255 255 stdin.flush()
256 256 except IOError:
257 257 badresponse()
258 258
259 259 # Assume version 1 of wire protocol by default.
260 260 protoname = wireprotoserver.SSHV1
261 261 reupgraded = re.compile(b'^upgraded %s (.*)$' % re.escape(token))
262 262
263 263 lines = ['', 'dummy']
264 264 max_noise = 500
265 265 while lines[-1] and max_noise:
266 266 try:
267 267 l = stdout.readline()
268 268 _forwardoutput(ui, stderr)
269 269
270 270 # Look for reply to protocol upgrade request. It has a token
271 271 # in it, so there should be no false positives.
272 272 m = reupgraded.match(l)
273 273 if m:
274 274 protoname = m.group(1)
275 275 ui.debug('protocol upgraded to %s\n' % protoname)
276 276 # If an upgrade was handled, the ``hello`` and ``between``
277 277 # requests are ignored. The next output belongs to the
278 278 # protocol, so stop scanning lines.
279 279 break
280 280
281 281 # Otherwise it could be a banner, ``0\n`` response if server
282 282 # doesn't support upgrade.
283 283
284 284 if lines[-1] == '1\n' and l == '\n':
285 285 break
286 286 if l:
287 287 ui.debug('remote: ', l)
288 288 lines.append(l)
289 289 max_noise -= 1
290 290 except IOError:
291 291 badresponse()
292 292 else:
293 293 badresponse()
294 294
295 295 caps = set()
296 296
297 297 # For version 1, we should see a ``capabilities`` line in response to the
298 298 # ``hello`` command.
299 299 if protoname == wireprotoserver.SSHV1:
300 300 for l in reversed(lines):
301 301 # Look for response to ``hello`` command. Scan from the back so
302 302 # we don't misinterpret banner output as the command reply.
303 303 if l.startswith('capabilities:'):
304 304 caps.update(l[:-1].split(':')[1].split())
305 305 break
306 306 elif protoname == wireprotoserver.SSHV2:
307 307 # We see a line with number of bytes to follow and then a value
308 308 # looking like ``capabilities: *``.
309 309 line = stdout.readline()
310 310 try:
311 311 valuelen = int(line)
312 312 except ValueError:
313 313 badresponse()
314 314
315 315 capsline = stdout.read(valuelen)
316 316 if not capsline.startswith('capabilities: '):
317 317 badresponse()
318 318
319 319 ui.debug('remote: %s\n' % capsline)
320 320
321 321 caps.update(capsline.split(':')[1].split())
322 322 # Trailing newline.
323 323 stdout.read(1)
324 324
325 325 # Error if we couldn't find capabilities, this means:
326 326 #
327 327 # 1. Remote isn't a Mercurial server
328 328 # 2. Remote is a <0.9.1 Mercurial server
329 329 # 3. Remote is a future Mercurial server that dropped ``hello``
330 330 # and other attempted handshake mechanisms.
331 331 if not caps:
332 332 badresponse()
333 333
334 # Flush any output on stderr before proceeding.
335 _forwardoutput(ui, stderr)
336
334 337 return protoname, caps
335 338
336 339 class sshv1peer(wireproto.wirepeer):
337 340 def __init__(self, ui, url, proc, stdin, stdout, stderr, caps):
338 341 """Create a peer from an existing SSH connection.
339 342
340 343 ``proc`` is a handle on the underlying SSH process.
341 344 ``stdin``, ``stdout``, and ``stderr`` are handles on the stdio
342 345 pipes for that process.
343 346 ``caps`` is a set of capabilities supported by the remote.
344 347 """
345 348 self._url = url
346 349 self._ui = ui
347 350 # self._subprocess is unused. Keeping a handle on the process
348 351 # holds a reference and prevents it from being garbage collected.
349 352 self._subprocess = proc
353
354 # And we hook up our "doublepipe" wrapper to allow querying
355 # stderr any time we perform I/O.
356 stdout = doublepipe(ui, util.bufferedinputpipe(stdout), stderr)
357 stdin = doublepipe(ui, stdin, stderr)
358
350 359 self._pipeo = stdin
351 360 self._pipei = stdout
352 361 self._pipee = stderr
353 362 self._caps = caps
354 363
355 364 # Commands that have a "framed" response where the first line of the
356 365 # response contains the length of that response.
357 366 _FRAMED_COMMANDS = {
358 367 'batch',
359 368 }
360 369
361 370 # Begin of _basepeer interface.
362 371
363 372 @util.propertycache
364 373 def ui(self):
365 374 return self._ui
366 375
367 376 def url(self):
368 377 return self._url
369 378
370 379 def local(self):
371 380 return None
372 381
373 382 def peer(self):
374 383 return self
375 384
376 385 def canpush(self):
377 386 return True
378 387
379 388 def close(self):
380 389 pass
381 390
382 391 # End of _basepeer interface.
383 392
384 393 # Begin of _basewirecommands interface.
385 394
386 395 def capabilities(self):
387 396 return self._caps
388 397
389 398 # End of _basewirecommands interface.
390 399
391 400 def _readerr(self):
392 401 _forwardoutput(self.ui, self._pipee)
393 402
394 403 def _abort(self, exception):
395 404 self._cleanup()
396 405 raise exception
397 406
398 407 def _cleanup(self):
399 408 _cleanuppipes(self.ui, self._pipei, self._pipeo, self._pipee)
400 409
401 410 __del__ = _cleanup
402 411
403 412 def _sendrequest(self, cmd, args, framed=False):
404 413 if (self.ui.debugflag
405 414 and self.ui.configbool('devel', 'debug.peer-request')):
406 415 dbg = self.ui.debug
407 416 line = 'devel-peer-request: %s\n'
408 417 dbg(line % cmd)
409 418 for key, value in sorted(args.items()):
410 419 if not isinstance(value, dict):
411 420 dbg(line % ' %s: %d bytes' % (key, len(value)))
412 421 else:
413 422 for dk, dv in sorted(value.items()):
414 423 dbg(line % ' %s-%s: %d' % (key, dk, len(dv)))
415 424 self.ui.debug("sending %s command\n" % cmd)
416 425 self._pipeo.write("%s\n" % cmd)
417 426 _func, names = wireproto.commands[cmd]
418 427 keys = names.split()
419 428 wireargs = {}
420 429 for k in keys:
421 430 if k == '*':
422 431 wireargs['*'] = args
423 432 break
424 433 else:
425 434 wireargs[k] = args[k]
426 435 del args[k]
427 436 for k, v in sorted(wireargs.iteritems()):
428 437 self._pipeo.write("%s %d\n" % (k, len(v)))
429 438 if isinstance(v, dict):
430 439 for dk, dv in v.iteritems():
431 440 self._pipeo.write("%s %d\n" % (dk, len(dv)))
432 441 self._pipeo.write(dv)
433 442 else:
434 443 self._pipeo.write(v)
435 444 self._pipeo.flush()
436 445
437 446 # We know exactly how many bytes are in the response. So return a proxy
438 447 # around the raw output stream that allows reading exactly this many
439 448 # bytes. Callers then can read() without fear of overrunning the
440 449 # response.
441 450 if framed:
442 451 amount = self._getamount()
443 452 return util.cappedreader(self._pipei, amount)
444 453
445 454 return self._pipei
446 455
447 456 def _callstream(self, cmd, **args):
448 457 args = pycompat.byteskwargs(args)
449 458 return self._sendrequest(cmd, args, framed=cmd in self._FRAMED_COMMANDS)
450 459
451 460 def _callcompressable(self, cmd, **args):
452 461 args = pycompat.byteskwargs(args)
453 462 return self._sendrequest(cmd, args, framed=cmd in self._FRAMED_COMMANDS)
454 463
455 464 def _call(self, cmd, **args):
456 465 args = pycompat.byteskwargs(args)
457 466 return self._sendrequest(cmd, args, framed=True).read()
458 467
459 468 def _callpush(self, cmd, fp, **args):
460 469 r = self._call(cmd, **args)
461 470 if r:
462 471 return '', r
463 472 for d in iter(lambda: fp.read(4096), ''):
464 473 self._writeframed(d)
465 474 self._writeframed("", flush=True)
466 475 r = self._readframed()
467 476 if r:
468 477 return '', r
469 478 return self._readframed(), ''
470 479
471 480 def _calltwowaystream(self, cmd, fp, **args):
472 481 r = self._call(cmd, **args)
473 482 if r:
474 483 # XXX needs to be made better
475 484 raise error.Abort(_('unexpected remote reply: %s') % r)
476 485 for d in iter(lambda: fp.read(4096), ''):
477 486 self._writeframed(d)
478 487 self._writeframed("", flush=True)
479 488 return self._pipei
480 489
481 490 def _getamount(self):
482 491 l = self._pipei.readline()
483 492 if l == '\n':
484 493 self._readerr()
485 494 msg = _('check previous remote output')
486 495 self._abort(error.OutOfBandError(hint=msg))
487 496 self._readerr()
488 497 try:
489 498 return int(l)
490 499 except ValueError:
491 500 self._abort(error.ResponseError(_("unexpected response:"), l))
492 501
493 502 def _readframed(self):
494 503 return self._pipei.read(self._getamount())
495 504
496 505 def _writeframed(self, data, flush=False):
497 506 self._pipeo.write("%d\n" % len(data))
498 507 if data:
499 508 self._pipeo.write(data)
500 509 if flush:
501 510 self._pipeo.flush()
502 511 self._readerr()
503 512
504 513 class sshv2peer(sshv1peer):
505 514 """A peer that speakers version 2 of the transport protocol."""
506 515 # Currently version 2 is identical to version 1 post handshake.
507 516 # And handshake is performed before the peer is instantiated. So
508 517 # we need no custom code.
509 518
510 519 def instance(ui, path, create):
511 520 """Create an SSH peer.
512 521
513 522 The returned object conforms to the ``wireproto.wirepeer`` interface.
514 523 """
515 524 u = util.url(path, parsequery=False, parsefragment=False)
516 525 if u.scheme != 'ssh' or not u.host or u.path is None:
517 526 raise error.RepoError(_("couldn't parse location %s") % path)
518 527
519 528 util.checksafessh(path)
520 529
521 530 if u.passwd is not None:
522 531 raise error.RepoError(_('password in URL not supported'))
523 532
524 533 sshcmd = ui.config('ui', 'ssh')
525 534 remotecmd = ui.config('ui', 'remotecmd')
526 535 sshaddenv = dict(ui.configitems('sshenv'))
527 536 sshenv = util.shellenviron(sshaddenv)
528 537 remotepath = u.path or '.'
529 538
530 539 args = util.sshargs(sshcmd, u.host, u.user, u.port)
531 540
532 541 if create:
533 542 cmd = '%s %s %s' % (sshcmd, args,
534 543 util.shellquote('%s init %s' %
535 544 (_serverquote(remotecmd), _serverquote(remotepath))))
536 545 ui.debug('running %s\n' % cmd)
537 546 res = ui.system(cmd, blockedtag='sshpeer', environ=sshenv)
538 547 if res != 0:
539 548 raise error.RepoError(_('could not create remote repo'))
540 549
541 550 proc, stdin, stdout, stderr = _makeconnection(ui, sshcmd, args, remotecmd,
542 551 remotepath, sshenv)
543 552
544 553 try:
545 554 protoname, caps = _performhandshake(ui, stdin, stdout, stderr)
546 555 except Exception:
547 556 _cleanuppipes(ui, stdout, stdin, stderr)
548 557 raise
549 558
550 559 if protoname == wireprotoserver.SSHV1:
551 560 return sshv1peer(ui, path, proc, stdin, stdout, stderr, caps)
552 561 elif protoname == wireprotoserver.SSHV2:
553 562 return sshv2peer(ui, path, proc, stdin, stdout, stderr, caps)
554 563 else:
555 564 _cleanuppipes(ui, stdout, stdin, stderr)
556 565 raise error.RepoError(_('unknown version of SSH protocol: %s') %
557 566 protoname)
@@ -1,76 +1,80 b''
1 1 # Test that certain objects conform to well-defined interfaces.
2 2
3 3 from __future__ import absolute_import, print_function
4 4
5 5 from mercurial import (
6 6 bundlerepo,
7 7 httppeer,
8 8 localrepo,
9 9 sshpeer,
10 10 statichttprepo,
11 11 ui as uimod,
12 12 unionrepo,
13 13 )
14 14
15 15 def checkobject(o):
16 16 """Verify a constructed object conforms to interface rules.
17 17
18 18 An object must have __abstractmethods__ defined.
19 19
20 20 All "public" attributes of the object (attributes not prefixed with
21 21 an underscore) must be in __abstractmethods__ or appear on a base class
22 22 with __abstractmethods__.
23 23 """
24 24 name = o.__class__.__name__
25 25
26 26 allowed = set()
27 27 for cls in o.__class__.__mro__:
28 28 if not getattr(cls, '__abstractmethods__', set()):
29 29 continue
30 30
31 31 allowed |= cls.__abstractmethods__
32 32 allowed |= {a for a in dir(cls) if not a.startswith('_')}
33 33
34 34 if not allowed:
35 35 print('%s does not have abstract methods' % name)
36 36 return
37 37
38 38 public = {a for a in dir(o) if not a.startswith('_')}
39 39
40 40 for attr in sorted(public - allowed):
41 41 print('public attributes not in abstract interface: %s.%s' % (
42 42 name, attr))
43 43
44 44 # Facilitates testing localpeer.
45 45 class dummyrepo(object):
46 46 def __init__(self):
47 47 self.ui = uimod.ui()
48 48 def filtered(self, name):
49 49 pass
50 50 def _restrictcapabilities(self, caps):
51 51 pass
52 52
53 53 # Facilitates testing sshpeer without requiring an SSH server.
54 54 class badpeer(httppeer.httppeer):
55 55 def __init__(self):
56 56 super(badpeer, self).__init__(uimod.ui(), 'http://localhost')
57 57 self.badattribute = True
58 58
59 59 def badmethod(self):
60 60 pass
61 61
62 class dummypipe(object):
63 def close(self):
64 pass
65
62 66 def main():
63 67 ui = uimod.ui()
64 68
65 69 checkobject(badpeer())
66 70 checkobject(httppeer.httppeer(ui, 'http://localhost'))
67 71 checkobject(localrepo.localpeer(dummyrepo()))
68 checkobject(sshpeer.sshv1peer(ui, 'ssh://localhost/foo', None, None, None,
69 None, None))
70 checkobject(sshpeer.sshv2peer(ui, 'ssh://localhost/foo', None, None, None,
71 None, None))
72 checkobject(sshpeer.sshv1peer(ui, 'ssh://localhost/foo', None, dummypipe(),
73 dummypipe(), None, None))
74 checkobject(sshpeer.sshv2peer(ui, 'ssh://localhost/foo', None, dummypipe(),
75 dummypipe(), None, None))
72 76 checkobject(bundlerepo.bundlepeer(dummyrepo()))
73 77 checkobject(statichttprepo.statichttppeer(dummyrepo()))
74 78 checkobject(unionrepo.unionpeer(dummyrepo()))
75 79
76 80 main()
General Comments 0
You need to be logged in to leave comments. Login now