Correct a parameter order swap in "diffusion.historyquery" for Mercurial
[phabricator.git] / src / applications / diffusion / ssh / DiffusionSubversionServeSSHWorkflow.php
blob76dae059718550b9216b7243ebaf326e78afe97a
1 <?php
3 /**
4 * This protocol has a good spec here:
6 * http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
7 */
8 final class DiffusionSubversionServeSSHWorkflow
9 extends DiffusionSubversionSSHWorkflow {
11 private $didSeeWrite;
13 private $inProtocol;
14 private $outProtocol;
16 private $inSeenGreeting;
18 private $outPhaseCount = 0;
20 private $internalBaseURI;
21 private $externalBaseURI;
22 private $peekBuffer;
23 private $command;
24 private $isProxying;
26 private function getCommand() {
27 return $this->command;
30 protected function didConstruct() {
31 $this->setName('svnserve');
32 $this->setArguments(
33 array(
34 array(
35 'name' => 'tunnel',
36 'short' => 't',
38 ));
41 protected function identifyRepository() {
42 // NOTE: In SVN, we need to read the first few protocol frames before we
43 // can determine which repository the user is trying to access. We're
44 // going to peek at the data on the wire to identify the repository.
46 $io_channel = $this->getIOChannel();
48 // Before the client will send us the first protocol frame, we need to send
49 // it a connection frame with server capabilities. To figure out the
50 // correct frame we're going to start `svnserve`, read the frame from it,
51 // send it to the client, then kill the subprocess.
53 // TODO: This is pretty inelegant and the protocol frame will change very
54 // rarely. We could cache it if we can find a reasonable way to dirty the
55 // cache.
57 $command = csprintf('svnserve -t');
58 $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
59 $future = new ExecFuture('%C', $command);
60 $exec_channel = new PhutilExecChannel($future);
61 $exec_protocol = new DiffusionSubversionWireProtocol();
63 while (true) {
64 PhutilChannel::waitForAny(array($exec_channel));
65 $exec_channel->update();
67 $exec_message = $exec_channel->read();
68 if ($exec_message !== null) {
69 $messages = $exec_protocol->writeData($exec_message);
70 if ($messages) {
71 $message = head($messages);
72 $raw = $message['raw'];
74 // Write the greeting frame to the client.
75 $io_channel->write($raw);
77 // Kill the subprocess.
78 $future->resolveKill();
79 break;
83 if (!$exec_channel->isOpenForReading()) {
84 throw new Exception(
85 pht(
86 '%s subprocess exited before emitting a protocol frame.',
87 'svnserve'));
91 $io_protocol = new DiffusionSubversionWireProtocol();
92 while (true) {
93 PhutilChannel::waitForAny(array($io_channel));
94 $io_channel->update();
96 $in_message = $io_channel->read();
97 if ($in_message !== null) {
98 $this->peekBuffer .= $in_message;
99 if (strlen($this->peekBuffer) > (1024 * 1024)) {
100 throw new Exception(
101 pht(
102 'Client transmitted more than 1MB of data without transmitting '.
103 'a recognizable protocol frame.'));
106 $messages = $io_protocol->writeData($in_message);
107 if ($messages) {
108 $message = head($messages);
109 $struct = $message['structure'];
111 // This is the:
113 // ( version ( cap1 ... ) url ... )
115 // The `url` allows us to identify the repository.
117 $uri = $struct[2]['value'];
118 $path = $this->getPathFromSubversionURI($uri);
120 return $this->loadRepositoryWithPath(
121 $path,
122 PhabricatorRepositoryType::REPOSITORY_TYPE_SVN);
126 if (!$io_channel->isOpenForReading()) {
127 throw new Exception(
128 pht(
129 'Client closed connection before sending a complete protocol '.
130 'frame.'));
133 // If the client has disconnected, kill the subprocess and bail.
134 if (!$io_channel->isOpenForWriting()) {
135 throw new Exception(
136 pht(
137 'Client closed connection before receiving response.'));
142 protected function executeRepositoryOperations() {
143 $repository = $this->getRepository();
145 $args = $this->getArgs();
146 if (!$args->getArg('tunnel')) {
147 throw new Exception(pht('Expected `%s`!', 'svnserve -t'));
150 if ($this->shouldProxy()) {
151 // NOTE: We're always requesting a writable device here. The request
152 // might be read-only, but we can't currently tell, and SVN requests
153 // can mix reads and writes.
154 $command = $this->getProxyCommand(true);
155 $this->isProxying = true;
156 $cwd = null;
157 } else {
158 $command = csprintf(
159 'svnserve -t --tunnel-user=%s',
160 $this->getSSHUser()->getUsername());
161 $cwd = PhabricatorEnv::getEmptyCWD();
164 $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
165 $future = new ExecFuture('%C', $command);
167 // If we're receiving a commit, svnserve will fail to execute the commit
168 // hook with an unhelpful error if the CWD isn't readable by the user we
169 // are sudoing to. Switch to a readable, empty CWD before running
170 // svnserve. See T10941.
171 if ($cwd !== null) {
172 $future->setCWD($cwd);
175 $this->inProtocol = new DiffusionSubversionWireProtocol();
176 $this->outProtocol = new DiffusionSubversionWireProtocol();
178 $this->command = id($this->newPassthruCommand())
179 ->setIOChannel($this->getIOChannel())
180 ->setCommandChannelFromExecFuture($future)
181 ->setWillWriteCallback(array($this, 'willWriteMessageCallback'))
182 ->setWillReadCallback(array($this, 'willReadMessageCallback'));
184 $this->command->setPauseIOReads(true);
186 $err = $this->command->execute();
188 if (!$err && $this->didSeeWrite) {
189 $this->getRepository()->writeStatusMessage(
190 PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,
191 PhabricatorRepositoryStatusMessage::CODE_OKAY);
194 return $err;
197 public function willWriteMessageCallback(
198 PhabricatorSSHPassthruCommand $command,
199 $message) {
201 $proto = $this->inProtocol;
202 $messages = $proto->writeData($message);
204 $result = array();
205 foreach ($messages as $message) {
206 $message_raw = $message['raw'];
207 $struct = $message['structure'];
209 if (!$this->inSeenGreeting) {
210 $this->inSeenGreeting = true;
212 // The first message the client sends looks like:
214 // ( version ( cap1 ... ) url ... )
216 // We want to grab the URL, load the repository, make sure it exists and
217 // is accessible, and then replace it with the location of the
218 // repository on disk.
220 $uri = $struct[2]['value'];
221 $struct[2]['value'] = $this->makeInternalURI($uri);
223 $message_raw = $proto->serializeStruct($struct);
224 } else if (isset($struct[0]) && $struct[0]['type'] == 'word') {
226 if (!$proto->isReadOnlyCommand($struct)) {
227 $this->didSeeWrite = true;
228 $this->requireWriteAccess($struct[0]['value']);
231 // Several other commands also pass in URLs. We need to translate
232 // all of these into the internal representation; this also makes sure
233 // they're valid and accessible.
235 switch ($struct[0]['value']) {
236 case 'reparent':
237 // ( reparent ( url ) )
238 $struct[1]['value'][0]['value'] = $this->makeInternalURI(
239 $struct[1]['value'][0]['value']);
240 $message_raw = $proto->serializeStruct($struct);
241 break;
242 case 'switch':
243 // ( switch ( ( rev ) target recurse url ... ) )
244 $struct[1]['value'][3]['value'] = $this->makeInternalURI(
245 $struct[1]['value'][3]['value']);
246 $message_raw = $proto->serializeStruct($struct);
247 break;
248 case 'diff':
249 // ( diff ( ( rev ) target recurse ignore-ancestry url ... ) )
250 $struct[1]['value'][4]['value'] = $this->makeInternalURI(
251 $struct[1]['value'][4]['value']);
252 $message_raw = $proto->serializeStruct($struct);
253 break;
254 case 'add-file':
255 case 'add-dir':
256 // ( add-file ( path dir-token file-token [ copy-path copy-rev ] ) )
257 // ( add-dir ( path parent child [ copy-path copy-rev ] ) )
258 if (isset($struct[1]['value'][3]['value'][0]['value'])) {
259 $copy_from = $struct[1]['value'][3]['value'][0]['value'];
260 $copy_from = $this->makeInternalURI($copy_from);
261 $struct[1]['value'][3]['value'][0]['value'] = $copy_from;
263 $message_raw = $proto->serializeStruct($struct);
264 break;
268 $result[] = $message_raw;
271 if (!$result) {
272 return null;
275 return implode('', $result);
278 public function willReadMessageCallback(
279 PhabricatorSSHPassthruCommand $command,
280 $message) {
282 $proto = $this->outProtocol;
283 $messages = $proto->writeData($message);
285 $result = array();
286 foreach ($messages as $message) {
287 $message_raw = $message['raw'];
288 $struct = $message['structure'];
290 if (isset($struct[0]) && ($struct[0]['type'] == 'word')) {
292 if ($struct[0]['value'] == 'success') {
293 switch ($this->outPhaseCount) {
294 case 0:
295 // This is the "greeting", which announces capabilities.
297 // We already sent this when we were figuring out which
298 // repository this request is for, so we aren't going to send
299 // it again.
301 // Instead, we're going to replay the client's response (which
302 // we also already read).
304 $command = $this->getCommand();
305 $command->writeIORead($this->peekBuffer);
306 $command->setPauseIOReads(false);
308 $message_raw = null;
309 break;
310 case 1:
311 // This responds to the client greeting, and announces auth.
312 break;
313 case 2:
314 // This responds to auth, which should be trivial over SSH.
315 break;
316 case 3:
317 // This contains the URI of the repository. We need to edit it;
318 // if it does not match what the client requested it will reject
319 // the response.
320 $struct[1]['value'][1]['value'] = $this->makeExternalURI(
321 $struct[1]['value'][1]['value']);
322 $message_raw = $proto->serializeStruct($struct);
323 break;
324 default:
325 // We don't care about other protocol frames.
326 break;
329 $this->outPhaseCount++;
330 } else if ($struct[0]['value'] == 'failure') {
331 // Find any error messages which include the internal URI, and
332 // replace the text with the external URI.
333 foreach ($struct[1]['value'] as $key => $error) {
334 $code = $error['value'][0]['value'];
335 $message = $error['value'][1]['value'];
337 $message = str_replace(
338 $this->internalBaseURI,
339 $this->externalBaseURI,
340 $message);
342 // Derp derp derp derp derp. The structure looks like this:
343 // ( failure ( ( code message ... ) ... ) )
344 $struct[1]['value'][$key]['value'][1]['value'] = $message;
346 $message_raw = $proto->serializeStruct($struct);
351 if ($message_raw !== null) {
352 $result[] = $message_raw;
356 if (!$result) {
357 return null;
360 return implode('', $result);
363 private function getPathFromSubversionURI($uri_string) {
364 $uri = new PhutilURI($uri_string);
366 $proto = $uri->getProtocol();
367 if ($proto !== 'svn+ssh') {
368 throw new Exception(
369 pht(
370 'Protocol for URI "%s" MUST be "%s".',
371 $uri_string,
372 'svn+ssh'));
374 $path = $uri->getPath();
376 // Subversion presumably deals with this, but make sure there's nothing
377 // sketchy going on with the URI.
378 if (preg_match('(/\\.\\./)', $path)) {
379 throw new Exception(
380 pht(
381 'String "%s" is invalid in path specification "%s".',
382 '/../',
383 $uri_string));
386 $path = $this->normalizeSVNPath($path);
388 return $path;
391 private function makeInternalURI($uri_string) {
392 if ($this->isProxying) {
393 return $uri_string;
396 $uri = new PhutilURI($uri_string);
398 $repository = $this->getRepository();
400 $path = $this->getPathFromSubversionURI($uri_string);
401 $external_base = $this->getBaseRequestPath();
403 // Replace "/diffusion/X" in the request with the repository local path,
404 // so "/diffusion/X/master/" becomes "/path/to/repository/X/master/".
405 $local_path = rtrim($repository->getLocalPath(), '/');
406 $path = $local_path.substr($path, strlen($external_base));
408 // NOTE: We are intentionally NOT removing username information from the
409 // URI. Subversion retains it over the course of the request and considers
410 // two repositories with different username identifiers to be distinct and
411 // incompatible.
413 $uri->setPath($path);
415 // If this is happening during the handshake, these are the base URIs for
416 // the request.
417 if ($this->externalBaseURI === null) {
418 $pre = (string)id(clone $uri)->setPath('');
420 $external_path = $external_base;
421 $external_path = $this->normalizeSVNPath($external_path);
422 $this->externalBaseURI = $pre.$external_path;
424 $internal_path = rtrim($repository->getLocalPath(), '/');
425 $internal_path = $this->normalizeSVNPath($internal_path);
426 $this->internalBaseURI = $pre.$internal_path;
429 return (string)$uri;
432 private function makeExternalURI($uri) {
433 if ($this->isProxying) {
434 return $uri;
437 $internal = $this->internalBaseURI;
438 $external = $this->externalBaseURI;
440 if (strncmp($uri, $internal, strlen($internal)) === 0) {
441 $uri = $external.substr($uri, strlen($internal));
444 return $uri;
447 private function normalizeSVNPath($path) {
448 // Subversion normalizes redundant slashes internally, so normalize them
449 // here as well to make sure things match up.
450 $path = preg_replace('(/+)', '/', $path);
452 return $path;
455 protected function raiseWrongVCSException(
456 PhabricatorRepository $repository) {
457 throw new Exception(
458 pht(
459 'This repository ("%s") is not a Subversion repository. Use "%s" to '.
460 'interact with this repository.',
461 $repository->getDisplayName(),
462 $repository->getVersionControlSystem()));