Path: blob/master/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php
12242 views
<?php12/**3* This protocol has a good spec here:4*5* http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol6*/7final class DiffusionSubversionServeSSHWorkflow8extends DiffusionSubversionSSHWorkflow {910private $didSeeWrite;1112private $inProtocol;13private $outProtocol;1415private $inSeenGreeting;1617private $outPhaseCount = 0;1819private $internalBaseURI;20private $externalBaseURI;21private $peekBuffer;22private $command;23private $isProxying;2425private function getCommand() {26return $this->command;27}2829protected function didConstruct() {30$this->setName('svnserve');31$this->setArguments(32array(33array(34'name' => 'tunnel',35'short' => 't',36),37));38}3940protected function identifyRepository() {41// NOTE: In SVN, we need to read the first few protocol frames before we42// can determine which repository the user is trying to access. We're43// going to peek at the data on the wire to identify the repository.4445$io_channel = $this->getIOChannel();4647// Before the client will send us the first protocol frame, we need to send48// it a connection frame with server capabilities. To figure out the49// correct frame we're going to start `svnserve`, read the frame from it,50// send it to the client, then kill the subprocess.5152// TODO: This is pretty inelegant and the protocol frame will change very53// rarely. We could cache it if we can find a reasonable way to dirty the54// cache.5556$command = csprintf('svnserve -t');57$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);58$future = new ExecFuture('%C', $command);59$exec_channel = new PhutilExecChannel($future);60$exec_protocol = new DiffusionSubversionWireProtocol();6162while (true) {63PhutilChannel::waitForAny(array($exec_channel));64$exec_channel->update();6566$exec_message = $exec_channel->read();67if ($exec_message !== null) {68$messages = $exec_protocol->writeData($exec_message);69if ($messages) {70$message = head($messages);71$raw = $message['raw'];7273// Write the greeting frame to the client.74$io_channel->write($raw);7576// Kill the subprocess.77$future->resolveKill();78break;79}80}8182if (!$exec_channel->isOpenForReading()) {83throw new Exception(84pht(85'%s subprocess exited before emitting a protocol frame.',86'svnserve'));87}88}8990$io_protocol = new DiffusionSubversionWireProtocol();91while (true) {92PhutilChannel::waitForAny(array($io_channel));93$io_channel->update();9495$in_message = $io_channel->read();96if ($in_message !== null) {97$this->peekBuffer .= $in_message;98if (strlen($this->peekBuffer) > (1024 * 1024)) {99throw new Exception(100pht(101'Client transmitted more than 1MB of data without transmitting '.102'a recognizable protocol frame.'));103}104105$messages = $io_protocol->writeData($in_message);106if ($messages) {107$message = head($messages);108$struct = $message['structure'];109110// This is the:111//112// ( version ( cap1 ... ) url ... )113//114// The `url` allows us to identify the repository.115116$uri = $struct[2]['value'];117$path = $this->getPathFromSubversionURI($uri);118119return $this->loadRepositoryWithPath(120$path,121PhabricatorRepositoryType::REPOSITORY_TYPE_SVN);122}123}124125if (!$io_channel->isOpenForReading()) {126throw new Exception(127pht(128'Client closed connection before sending a complete protocol '.129'frame.'));130}131132// If the client has disconnected, kill the subprocess and bail.133if (!$io_channel->isOpenForWriting()) {134throw new Exception(135pht(136'Client closed connection before receiving response.'));137}138}139}140141protected function executeRepositoryOperations() {142$repository = $this->getRepository();143144$args = $this->getArgs();145if (!$args->getArg('tunnel')) {146throw new Exception(pht('Expected `%s`!', 'svnserve -t'));147}148149if ($this->shouldProxy()) {150// NOTE: We're always requesting a writable device here. The request151// might be read-only, but we can't currently tell, and SVN requests152// can mix reads and writes.153$command = $this->getProxyCommand(true);154$this->isProxying = true;155$cwd = null;156} else {157$command = csprintf(158'svnserve -t --tunnel-user=%s',159$this->getSSHUser()->getUsername());160$cwd = PhabricatorEnv::getEmptyCWD();161}162163$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);164$future = new ExecFuture('%C', $command);165166// If we're receiving a commit, svnserve will fail to execute the commit167// hook with an unhelpful error if the CWD isn't readable by the user we168// are sudoing to. Switch to a readable, empty CWD before running169// svnserve. See T10941.170if ($cwd !== null) {171$future->setCWD($cwd);172}173174$this->inProtocol = new DiffusionSubversionWireProtocol();175$this->outProtocol = new DiffusionSubversionWireProtocol();176177$this->command = id($this->newPassthruCommand())178->setIOChannel($this->getIOChannel())179->setCommandChannelFromExecFuture($future)180->setWillWriteCallback(array($this, 'willWriteMessageCallback'))181->setWillReadCallback(array($this, 'willReadMessageCallback'));182183$this->command->setPauseIOReads(true);184185$err = $this->command->execute();186187if (!$err && $this->didSeeWrite) {188$this->getRepository()->writeStatusMessage(189PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,190PhabricatorRepositoryStatusMessage::CODE_OKAY);191}192193return $err;194}195196public function willWriteMessageCallback(197PhabricatorSSHPassthruCommand $command,198$message) {199200$proto = $this->inProtocol;201$messages = $proto->writeData($message);202203$result = array();204foreach ($messages as $message) {205$message_raw = $message['raw'];206$struct = $message['structure'];207208if (!$this->inSeenGreeting) {209$this->inSeenGreeting = true;210211// The first message the client sends looks like:212//213// ( version ( cap1 ... ) url ... )214//215// We want to grab the URL, load the repository, make sure it exists and216// is accessible, and then replace it with the location of the217// repository on disk.218219$uri = $struct[2]['value'];220$struct[2]['value'] = $this->makeInternalURI($uri);221222$message_raw = $proto->serializeStruct($struct);223} else if (isset($struct[0]) && $struct[0]['type'] == 'word') {224225if (!$proto->isReadOnlyCommand($struct)) {226$this->didSeeWrite = true;227$this->requireWriteAccess($struct[0]['value']);228}229230// Several other commands also pass in URLs. We need to translate231// all of these into the internal representation; this also makes sure232// they're valid and accessible.233234switch ($struct[0]['value']) {235case 'reparent':236// ( reparent ( url ) )237$struct[1]['value'][0]['value'] = $this->makeInternalURI(238$struct[1]['value'][0]['value']);239$message_raw = $proto->serializeStruct($struct);240break;241case 'switch':242// ( switch ( ( rev ) target recurse url ... ) )243$struct[1]['value'][3]['value'] = $this->makeInternalURI(244$struct[1]['value'][3]['value']);245$message_raw = $proto->serializeStruct($struct);246break;247case 'diff':248// ( diff ( ( rev ) target recurse ignore-ancestry url ... ) )249$struct[1]['value'][4]['value'] = $this->makeInternalURI(250$struct[1]['value'][4]['value']);251$message_raw = $proto->serializeStruct($struct);252break;253case 'add-file':254case 'add-dir':255// ( add-file ( path dir-token file-token [ copy-path copy-rev ] ) )256// ( add-dir ( path parent child [ copy-path copy-rev ] ) )257if (isset($struct[1]['value'][3]['value'][0]['value'])) {258$copy_from = $struct[1]['value'][3]['value'][0]['value'];259$copy_from = $this->makeInternalURI($copy_from);260$struct[1]['value'][3]['value'][0]['value'] = $copy_from;261}262$message_raw = $proto->serializeStruct($struct);263break;264}265}266267$result[] = $message_raw;268}269270if (!$result) {271return null;272}273274return implode('', $result);275}276277public function willReadMessageCallback(278PhabricatorSSHPassthruCommand $command,279$message) {280281$proto = $this->outProtocol;282$messages = $proto->writeData($message);283284$result = array();285foreach ($messages as $message) {286$message_raw = $message['raw'];287$struct = $message['structure'];288289if (isset($struct[0]) && ($struct[0]['type'] == 'word')) {290291if ($struct[0]['value'] == 'success') {292switch ($this->outPhaseCount) {293case 0:294// This is the "greeting", which announces capabilities.295296// We already sent this when we were figuring out which297// repository this request is for, so we aren't going to send298// it again.299300// Instead, we're going to replay the client's response (which301// we also already read).302303$command = $this->getCommand();304$command->writeIORead($this->peekBuffer);305$command->setPauseIOReads(false);306307$message_raw = null;308break;309case 1:310// This responds to the client greeting, and announces auth.311break;312case 2:313// This responds to auth, which should be trivial over SSH.314break;315case 3:316// This contains the URI of the repository. We need to edit it;317// if it does not match what the client requested it will reject318// the response.319$struct[1]['value'][1]['value'] = $this->makeExternalURI(320$struct[1]['value'][1]['value']);321$message_raw = $proto->serializeStruct($struct);322break;323default:324// We don't care about other protocol frames.325break;326}327328$this->outPhaseCount++;329} else if ($struct[0]['value'] == 'failure') {330// Find any error messages which include the internal URI, and331// replace the text with the external URI.332foreach ($struct[1]['value'] as $key => $error) {333$code = $error['value'][0]['value'];334$message = $error['value'][1]['value'];335336$message = str_replace(337$this->internalBaseURI,338$this->externalBaseURI,339$message);340341// Derp derp derp derp derp. The structure looks like this:342// ( failure ( ( code message ... ) ... ) )343$struct[1]['value'][$key]['value'][1]['value'] = $message;344}345$message_raw = $proto->serializeStruct($struct);346}347348}349350if ($message_raw !== null) {351$result[] = $message_raw;352}353}354355if (!$result) {356return null;357}358359return implode('', $result);360}361362private function getPathFromSubversionURI($uri_string) {363$uri = new PhutilURI($uri_string);364365$proto = $uri->getProtocol();366if ($proto !== 'svn+ssh') {367throw new Exception(368pht(369'Protocol for URI "%s" MUST be "%s".',370$uri_string,371'svn+ssh'));372}373$path = $uri->getPath();374375// Subversion presumably deals with this, but make sure there's nothing376// sketchy going on with the URI.377if (preg_match('(/\\.\\./)', $path)) {378throw new Exception(379pht(380'String "%s" is invalid in path specification "%s".',381'/../',382$uri_string));383}384385$path = $this->normalizeSVNPath($path);386387return $path;388}389390private function makeInternalURI($uri_string) {391if ($this->isProxying) {392return $uri_string;393}394395$uri = new PhutilURI($uri_string);396397$repository = $this->getRepository();398399$path = $this->getPathFromSubversionURI($uri_string);400$external_base = $this->getBaseRequestPath();401402// Replace "/diffusion/X" in the request with the repository local path,403// so "/diffusion/X/master/" becomes "/path/to/repository/X/master/".404$local_path = rtrim($repository->getLocalPath(), '/');405$path = $local_path.substr($path, strlen($external_base));406407// NOTE: We are intentionally NOT removing username information from the408// URI. Subversion retains it over the course of the request and considers409// two repositories with different username identifiers to be distinct and410// incompatible.411412$uri->setPath($path);413414// If this is happening during the handshake, these are the base URIs for415// the request.416if ($this->externalBaseURI === null) {417$pre = (string)id(clone $uri)->setPath('');418419$external_path = $external_base;420$external_path = $this->normalizeSVNPath($external_path);421$this->externalBaseURI = $pre.$external_path;422423$internal_path = rtrim($repository->getLocalPath(), '/');424$internal_path = $this->normalizeSVNPath($internal_path);425$this->internalBaseURI = $pre.$internal_path;426}427428return (string)$uri;429}430431private function makeExternalURI($uri) {432if ($this->isProxying) {433return $uri;434}435436$internal = $this->internalBaseURI;437$external = $this->externalBaseURI;438439if (strncmp($uri, $internal, strlen($internal)) === 0) {440$uri = $external.substr($uri, strlen($internal));441}442443return $uri;444}445446private function normalizeSVNPath($path) {447// Subversion normalizes redundant slashes internally, so normalize them448// here as well to make sure things match up.449$path = preg_replace('(/+)', '/', $path);450451return $path;452}453454protected function raiseWrongVCSException(455PhabricatorRepository $repository) {456throw new Exception(457pht(458'This repository ("%s") is not a Subversion repository. Use "%s" to '.459'interact with this repository.',460$repository->getDisplayName(),461$repository->getVersionControlSystem()));462}463464}465466467