@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 465 lines 15 kB view raw
1<?php 2 3/** 4 * This protocol has a good spec here: 5 * 6 * http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol 7 */ 8final class DiffusionSubversionServeSSHWorkflow 9 extends DiffusionSubversionSSHWorkflow { 10 11 private $didSeeWrite; 12 13 private $inProtocol; 14 private $outProtocol; 15 16 private $inSeenGreeting; 17 18 private $outPhaseCount = 0; 19 20 private $internalBaseURI; 21 private $externalBaseURI; 22 private $peekBuffer; 23 private $command; 24 private $isProxying; 25 26 private function getCommand() { 27 return $this->command; 28 } 29 30 protected function didConstruct() { 31 $this->setName('svnserve'); 32 $this->setArguments( 33 array( 34 array( 35 'name' => 'tunnel', 36 'short' => 't', 37 ), 38 )); 39 } 40 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. 45 46 $io_channel = $this->getIOChannel(); 47 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. 52 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. 56 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(); 62 63 while (true) { 64 PhutilChannel::waitForAny(array($exec_channel)); 65 $exec_channel->update(); 66 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']; 73 74 // Write the greeting frame to the client. 75 $io_channel->write($raw); 76 77 // Kill the subprocess. 78 $future->resolveKill(); 79 break; 80 } 81 } 82 83 if (!$exec_channel->isOpenForReading()) { 84 throw new Exception( 85 pht( 86 '%s subprocess exited before emitting a protocol frame.', 87 'svnserve')); 88 } 89 } 90 91 $io_protocol = new DiffusionSubversionWireProtocol(); 92 while (true) { 93 PhutilChannel::waitForAny(array($io_channel)); 94 $io_channel->update(); 95 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.')); 104 } 105 106 $messages = $io_protocol->writeData($in_message); 107 if ($messages) { 108 $message = head($messages); 109 $struct = $message['structure']; 110 111 // This is the: 112 // 113 // ( version ( cap1 ... ) url ... ) 114 // 115 // The `url` allows us to identify the repository. 116 117 $uri = $struct[2]['value']; 118 $path = $this->getPathFromSubversionURI($uri); 119 120 return $this->loadRepositoryWithPath( 121 $path, 122 PhabricatorRepositoryType::REPOSITORY_TYPE_SVN); 123 } 124 } 125 126 if (!$io_channel->isOpenForReading()) { 127 throw new Exception( 128 pht( 129 'Client closed connection before sending a complete protocol '. 130 'frame.')); 131 } 132 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.')); 138 } 139 } 140 } 141 142 protected function executeRepositoryOperations() { 143 $repository = $this->getRepository(); 144 145 $args = $this->getArgs(); 146 if (!$args->getArg('tunnel')) { 147 throw new Exception(pht('Expected `%s`!', 'svnserve -t')); 148 } 149 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(); 162 } 163 164 $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); 165 $future = new ExecFuture('%C', $command); 166 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); 173 } 174 175 $this->inProtocol = new DiffusionSubversionWireProtocol(); 176 $this->outProtocol = new DiffusionSubversionWireProtocol(); 177 178 $this->command = id($this->newPassthruCommand()) 179 ->setIOChannel($this->getIOChannel()) 180 ->setCommandChannelFromExecFuture($future) 181 ->setWillWriteCallback(array($this, 'willWriteMessageCallback')) 182 ->setWillReadCallback(array($this, 'willReadMessageCallback')); 183 184 $this->command->setPauseIOReads(true); 185 186 $err = $this->command->execute(); 187 188 if (!$err && $this->didSeeWrite) { 189 $this->getRepository()->writeStatusMessage( 190 PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, 191 PhabricatorRepositoryStatusMessage::CODE_OKAY); 192 } 193 194 return $err; 195 } 196 197 public function willWriteMessageCallback( 198 PhabricatorSSHPassthruCommand $command, 199 $message) { 200 201 $proto = $this->inProtocol; 202 $messages = $proto->writeData($message); 203 204 $result = array(); 205 foreach ($messages as $message) { 206 $message_raw = $message['raw']; 207 $struct = $message['structure']; 208 209 if (!$this->inSeenGreeting) { 210 $this->inSeenGreeting = true; 211 212 // The first message the client sends looks like: 213 // 214 // ( version ( cap1 ... ) url ... ) 215 // 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. 219 220 $uri = $struct[2]['value']; 221 $struct[2]['value'] = $this->makeInternalURI($uri); 222 223 $message_raw = $proto->serializeStruct($struct); 224 } else if (isset($struct[0]) && $struct[0]['type'] == 'word') { 225 226 if (!$proto->isReadOnlyCommand($struct)) { 227 $this->didSeeWrite = true; 228 $this->requireWriteAccess($struct[0]['value']); 229 } 230 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. 234 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; 262 } 263 $message_raw = $proto->serializeStruct($struct); 264 break; 265 } 266 } 267 268 $result[] = $message_raw; 269 } 270 271 if (!$result) { 272 return null; 273 } 274 275 return implode('', $result); 276 } 277 278 public function willReadMessageCallback( 279 PhabricatorSSHPassthruCommand $command, 280 $message) { 281 282 $proto = $this->outProtocol; 283 $messages = $proto->writeData($message); 284 285 $result = array(); 286 foreach ($messages as $message) { 287 $message_raw = $message['raw']; 288 $struct = $message['structure']; 289 290 if (isset($struct[0]) && ($struct[0]['type'] == 'word')) { 291 292 if ($struct[0]['value'] == 'success') { 293 switch ($this->outPhaseCount) { 294 case 0: 295 // This is the "greeting", which announces capabilities. 296 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. 300 301 // Instead, we're going to replay the client's response (which 302 // we also already read). 303 304 $command = $this->getCommand(); 305 $command->writeIORead($this->peekBuffer); 306 $command->setPauseIOReads(false); 307 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; 327 } 328 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']; 336 337 $message = str_replace( 338 $this->internalBaseURI, 339 $this->externalBaseURI, 340 $message); 341 342 // Derp derp derp derp derp. The structure looks like this: 343 // ( failure ( ( code message ... ) ... ) ) 344 $struct[1]['value'][$key]['value'][1]['value'] = $message; 345 } 346 $message_raw = $proto->serializeStruct($struct); 347 } 348 349 } 350 351 if ($message_raw !== null) { 352 $result[] = $message_raw; 353 } 354 } 355 356 if (!$result) { 357 return null; 358 } 359 360 return implode('', $result); 361 } 362 363 private function getPathFromSubversionURI($uri_string) { 364 $uri = new PhutilURI($uri_string); 365 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')); 373 } 374 $path = $uri->getPath(); 375 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)); 384 } 385 386 $path = $this->normalizeSVNPath($path); 387 388 return $path; 389 } 390 391 private function makeInternalURI($uri_string) { 392 if ($this->isProxying) { 393 return $uri_string; 394 } 395 396 $uri = new PhutilURI($uri_string); 397 398 $repository = $this->getRepository(); 399 400 $path = $this->getPathFromSubversionURI($uri_string); 401 $external_base = $this->getBaseRequestPath(); 402 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)); 407 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. 412 413 $uri->setPath($path); 414 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(''); 419 420 $external_path = $external_base; 421 $external_path = $this->normalizeSVNPath($external_path); 422 $this->externalBaseURI = $pre.$external_path; 423 424 $internal_path = rtrim($repository->getLocalPath(), '/'); 425 $internal_path = $this->normalizeSVNPath($internal_path); 426 $this->internalBaseURI = $pre.$internal_path; 427 } 428 429 return (string)$uri; 430 } 431 432 private function makeExternalURI($uri) { 433 if ($this->isProxying) { 434 return $uri; 435 } 436 437 $internal = $this->internalBaseURI; 438 $external = $this->externalBaseURI; 439 440 if (strncmp($uri, $internal, strlen($internal)) === 0) { 441 $uri = $external.substr($uri, strlen($internal)); 442 } 443 444 return $uri; 445 } 446 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); 451 452 return $path; 453 } 454 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())); 463 } 464 465}