4 * This protocol has a good spec here:
6 * http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
8 final class DiffusionSubversionServeSSHWorkflow
9 extends DiffusionSubversionSSHWorkflow
{
16 private $inSeenGreeting;
18 private $outPhaseCount = 0;
20 private $internalBaseURI;
21 private $externalBaseURI;
26 private function getCommand() {
27 return $this->command
;
30 protected function didConstruct() {
31 $this->setName('svnserve');
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
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();
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);
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();
83 if (!$exec_channel->isOpenForReading()) {
86 '%s subprocess exited before emitting a protocol frame.',
91 $io_protocol = new DiffusionSubversionWireProtocol();
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)) {
102 'Client transmitted more than 1MB of data without transmitting '.
103 'a recognizable protocol frame.'));
106 $messages = $io_protocol->writeData($in_message);
108 $message = head($messages);
109 $struct = $message['structure'];
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(
122 PhabricatorRepositoryType
::REPOSITORY_TYPE_SVN
);
126 if (!$io_channel->isOpenForReading()) {
129 'Client closed connection before sending a complete protocol '.
133 // If the client has disconnected, kill the subprocess and bail.
134 if (!$io_channel->isOpenForWriting()) {
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;
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.
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
);
197 public function willWriteMessageCallback(
198 PhabricatorSSHPassthruCommand
$command,
201 $proto = $this->inProtocol
;
202 $messages = $proto->writeData($message);
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']) {
237 // ( reparent ( url ) )
238 $struct[1]['value'][0]['value'] = $this->makeInternalURI(
239 $struct[1]['value'][0]['value']);
240 $message_raw = $proto->serializeStruct($struct);
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);
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);
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);
268 $result[] = $message_raw;
275 return implode('', $result);
278 public function willReadMessageCallback(
279 PhabricatorSSHPassthruCommand
$command,
282 $proto = $this->outProtocol
;
283 $messages = $proto->writeData($message);
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
) {
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
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);
311 // This responds to the client greeting, and announces auth.
314 // This responds to auth, which should be trivial over SSH.
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
320 $struct[1]['value'][1]['value'] = $this->makeExternalURI(
321 $struct[1]['value'][1]['value']);
322 $message_raw = $proto->serializeStruct($struct);
325 // We don't care about other protocol frames.
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
,
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;
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') {
370 'Protocol for URI "%s" MUST be "%s".',
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)) {
381 'String "%s" is invalid in path specification "%s".',
386 $path = $this->normalizeSVNPath($path);
391 private function makeInternalURI($uri_string) {
392 if ($this->isProxying
) {
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
413 $uri->setPath($path);
415 // If this is happening during the handshake, these are the base URIs for
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;
432 private function makeExternalURI($uri) {
433 if ($this->isProxying
) {
437 $internal = $this->internalBaseURI
;
438 $external = $this->externalBaseURI
;
440 if (strncmp($uri, $internal, strlen($internal)) === 0) {
441 $uri = $external.substr($uri, strlen($internal));
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);
455 protected function raiseWrongVCSException(
456 PhabricatorRepository
$repository) {
459 'This repository ("%s") is not a Subversion repository. Use "%s" to '.
460 'interact with this repository.',
461 $repository->getDisplayName(),
462 $repository->getVersionControlSystem()));