<?php

/**
 * This protocol has a good spec here:
 *
 *   http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
 */
final class DiffusionSubversionServeSSHWorkflow
  extends DiffusionSubversionSSHWorkflow {

  private $didSeeWrite;

  private $inProtocol;
  private $outProtocol;

  private $inSeenGreeting;

  private $outPhaseCount = 0;

  private $internalBaseURI;
  private $externalBaseURI;
  private $peekBuffer;
  private $command;
  private $isProxying;

  private function getCommand() {
    return $this->command;
  }

  protected function didConstruct() {
    $this->setName('svnserve');
    $this->setArguments(
      array(
        array(
          'name' => 'tunnel',
          'short' => 't',
        ),
      ));
  }

  protected function identifyRepository() {
    // NOTE: In SVN, we need to read the first few protocol frames before we
    // can determine which repository the user is trying to access. We're
    // going to peek at the data on the wire to identify the repository.

    $io_channel = $this->getIOChannel();

    // Before the client will send us the first protocol frame, we need to send
    // it a connection frame with server capabilities. To figure out the
    // correct frame we're going to start `svnserve`, read the frame from it,
    // send it to the client, then kill the subprocess.

    // TODO: This is pretty inelegant and the protocol frame will change very
    // rarely. We could cache it if we can find a reasonable way to dirty the
    // cache.

    $command = csprintf('svnserve -t');
    $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
    $future = new ExecFuture('%C', $command);
    $exec_channel = new PhutilExecChannel($future);
    $exec_protocol = new DiffusionSubversionWireProtocol();

    while (true) {
      PhutilChannel::waitForAny(array($exec_channel));
      $exec_channel->update();

      $exec_message = $exec_channel->read();
      if ($exec_message !== null) {
        $messages = $exec_protocol->writeData($exec_message);
        if ($messages) {
          $message = head($messages);
          $raw = $message['raw'];

          // Write the greeting frame to the client.
          $io_channel->write($raw);

          // Kill the subprocess.
          $future->resolveKill();
          break;
        }
      }

      if (!$exec_channel->isOpenForReading()) {
        throw new Exception(
          pht(
            '%s subprocess exited before emitting a protocol frame.',
            'svnserve'));
      }
    }

    $io_protocol = new DiffusionSubversionWireProtocol();
    while (true) {
      PhutilChannel::waitForAny(array($io_channel));
      $io_channel->update();

      $in_message = $io_channel->read();
      if ($in_message !== null) {
        $this->peekBuffer .= $in_message;
        if (strlen($this->peekBuffer) > (1024 * 1024)) {
          throw new Exception(
            pht(
              'Client transmitted more than 1MB of data without transmitting '.
              'a recognizable protocol frame.'));
        }

        $messages = $io_protocol->writeData($in_message);
        if ($messages) {
          $message = head($messages);
          $struct = $message['structure'];

          // This is the:
          //
          //   ( version ( cap1 ... ) url ... )
          //
          // The `url` allows us to identify the repository.

          $uri = $struct[2]['value'];
          $path = $this->getPathFromSubversionURI($uri);

          return $this->loadRepositoryWithPath(
            $path,
            PhabricatorRepositoryType::REPOSITORY_TYPE_SVN);
        }
      }

      if (!$io_channel->isOpenForReading()) {
        throw new Exception(
          pht(
            'Client closed connection before sending a complete protocol '.
            'frame.'));
      }

      // If the client has disconnected, kill the subprocess and bail.
      if (!$io_channel->isOpenForWriting()) {
        throw new Exception(
          pht(
            'Client closed connection before receiving response.'));
      }
    }
  }

  protected function executeRepositoryOperations() {
    $repository = $this->getRepository();

    $args = $this->getArgs();
    if (!$args->getArg('tunnel')) {
      throw new Exception(pht('Expected `%s`!', 'svnserve -t'));
    }

    if ($this->shouldProxy()) {
      // NOTE: We're always requesting a writable device here. The request
      // might be read-only, but we can't currently tell, and SVN requests
      // can mix reads and writes.
      $command = $this->getProxyCommand(true);
      $this->isProxying = true;
      $cwd = null;
    } else {
      $command = csprintf(
        'svnserve -t --tunnel-user=%s',
        $this->getSSHUser()->getUsername());
      $cwd = PhabricatorEnv::getEmptyCWD();
    }

    $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
    $future = new ExecFuture('%C', $command);

    // If we're receiving a commit, svnserve will fail to execute the commit
    // hook with an unhelpful error if the CWD isn't readable by the user we
    // are sudoing to. Switch to a readable, empty CWD before running
    // svnserve. See T10941.
    if ($cwd !== null) {
      $future->setCWD($cwd);
    }

    $this->inProtocol = new DiffusionSubversionWireProtocol();
    $this->outProtocol = new DiffusionSubversionWireProtocol();

    $this->command = id($this->newPassthruCommand())
      ->setIOChannel($this->getIOChannel())
      ->setCommandChannelFromExecFuture($future)
      ->setWillWriteCallback(array($this, 'willWriteMessageCallback'))
      ->setWillReadCallback(array($this, 'willReadMessageCallback'));

    $this->command->setPauseIOReads(true);

    $err = $this->command->execute();

    if (!$err && $this->didSeeWrite) {
      $this->getRepository()->writeStatusMessage(
        PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,
        PhabricatorRepositoryStatusMessage::CODE_OKAY);
    }

    return $err;
  }

  public function willWriteMessageCallback(
    PhabricatorSSHPassthruCommand $command,
    $message) {

    $proto = $this->inProtocol;
    $messages = $proto->writeData($message);

    $result = array();
    foreach ($messages as $message) {
      $message_raw = $message['raw'];
      $struct = $message['structure'];

      if (!$this->inSeenGreeting) {
        $this->inSeenGreeting = true;

        // The first message the client sends looks like:
        //
        //   ( version ( cap1 ... ) url ... )
        //
        // We want to grab the URL, load the repository, make sure it exists and
        // is accessible, and then replace it with the location of the
        // repository on disk.

        $uri = $struct[2]['value'];
        $struct[2]['value'] = $this->makeInternalURI($uri);

        $message_raw = $proto->serializeStruct($struct);
      } else if (isset($struct[0]) && $struct[0]['type'] == 'word') {

        if (!$proto->isReadOnlyCommand($struct)) {
          $this->didSeeWrite = true;
          $this->requireWriteAccess($struct[0]['value']);
        }

        // Several other commands also pass in URLs. We need to translate
        // all of these into the internal representation; this also makes sure
        // they're valid and accessible.

        switch ($struct[0]['value']) {
          case 'reparent':
            // ( reparent ( url ) )
            $struct[1]['value'][0]['value'] = $this->makeInternalURI(
              $struct[1]['value'][0]['value']);
            $message_raw = $proto->serializeStruct($struct);
            break;
          case 'switch':
            // ( switch ( ( rev ) target recurse url ... ) )
            $struct[1]['value'][3]['value'] = $this->makeInternalURI(
              $struct[1]['value'][3]['value']);
            $message_raw = $proto->serializeStruct($struct);
            break;
          case 'diff':
            // ( diff ( ( rev ) target recurse ignore-ancestry url ... ) )
            $struct[1]['value'][4]['value'] = $this->makeInternalURI(
              $struct[1]['value'][4]['value']);
            $message_raw = $proto->serializeStruct($struct);
            break;
          case 'add-file':
          case 'add-dir':
            // ( add-file ( path dir-token file-token [ copy-path copy-rev ] ) )
            // ( add-dir ( path parent child [ copy-path copy-rev ] ) )
            if (isset($struct[1]['value'][3]['value'][0]['value'])) {
              $copy_from = $struct[1]['value'][3]['value'][0]['value'];
              $copy_from = $this->makeInternalURI($copy_from);
              $struct[1]['value'][3]['value'][0]['value'] = $copy_from;
            }
            $message_raw = $proto->serializeStruct($struct);
            break;
        }
      }

      $result[] = $message_raw;
    }

    if (!$result) {
      return null;
    }

    return implode('', $result);
  }

  public function willReadMessageCallback(
    PhabricatorSSHPassthruCommand $command,
    $message) {

    $proto = $this->outProtocol;
    $messages = $proto->writeData($message);

    $result = array();
    foreach ($messages as $message) {
      $message_raw = $message['raw'];
      $struct = $message['structure'];

      if (isset($struct[0]) && ($struct[0]['type'] == 'word')) {

        if ($struct[0]['value'] == 'success') {
          switch ($this->outPhaseCount) {
            case 0:
              // This is the "greeting", which announces capabilities.

              // We already sent this when we were figuring out which
              // repository this request is for, so we aren't going to send
              // it again.

              // Instead, we're going to replay the client's response (which
              // we also already read).

              $command = $this->getCommand();
              $command->writeIORead($this->peekBuffer);
              $command->setPauseIOReads(false);

              $message_raw = null;
              break;
            case 1:
              // This responds to the client greeting, and announces auth.
              break;
            case 2:
              // This responds to auth, which should be trivial over SSH.
              break;
            case 3:
              // This contains the URI of the repository. We need to edit it;
              // if it does not match what the client requested it will reject
              // the response.
              $struct[1]['value'][1]['value'] = $this->makeExternalURI(
                $struct[1]['value'][1]['value']);
              $message_raw = $proto->serializeStruct($struct);
              break;
            default:
              // We don't care about other protocol frames.
              break;
          }

          $this->outPhaseCount++;
        } else if ($struct[0]['value'] == 'failure') {
          // Find any error messages which include the internal URI, and
          // replace the text with the external URI.
          foreach ($struct[1]['value'] as $key => $error) {
            $code = $error['value'][0]['value'];
            $message = $error['value'][1]['value'];

            $message = str_replace(
              $this->internalBaseURI,
              $this->externalBaseURI,
              $message);

            // Derp derp derp derp derp. The structure looks like this:
            //   ( failure ( ( code message ... ) ... ) )
            $struct[1]['value'][$key]['value'][1]['value'] = $message;
          }
          $message_raw = $proto->serializeStruct($struct);
        }

      }

      if ($message_raw !== null) {
        $result[] = $message_raw;
      }
    }

    if (!$result) {
      return null;
    }

    return implode('', $result);
  }

  private function getPathFromSubversionURI($uri_string) {
    $uri = new PhutilURI($uri_string);

    $proto = $uri->getProtocol();
    if ($proto !== 'svn+ssh') {
      throw new Exception(
        pht(
          'Protocol for URI "%s" MUST be "%s".',
          $uri_string,
          'svn+ssh'));
    }
    $path = $uri->getPath();

    // Subversion presumably deals with this, but make sure there's nothing
    // sketchy going on with the URI.
    if (preg_match('(/\\.\\./)', $path)) {
      throw new Exception(
        pht(
          'String "%s" is invalid in path specification "%s".',
          '/../',
          $uri_string));
    }

    $path = $this->normalizeSVNPath($path);

    return $path;
  }

  private function makeInternalURI($uri_string) {
    if ($this->isProxying) {
      return $uri_string;
    }

    $uri = new PhutilURI($uri_string);

    $repository = $this->getRepository();

    $path = $this->getPathFromSubversionURI($uri_string);
    $external_base = $this->getBaseRequestPath();

    // Replace "/diffusion/X" in the request with the repository local path,
    // so "/diffusion/X/master/" becomes "/path/to/repository/X/master/".
    $local_path = rtrim($repository->getLocalPath(), '/');
    $path = $local_path.substr($path, strlen($external_base));

    // NOTE: We are intentionally NOT removing username information from the
    // URI. Subversion retains it over the course of the request and considers
    // two repositories with different username identifiers to be distinct and
    // incompatible.

    $uri->setPath($path);

    // If this is happening during the handshake, these are the base URIs for
    // the request.
    if ($this->externalBaseURI === null) {
      $pre = (string)id(clone $uri)->setPath('');

      $external_path = $external_base;
      $external_path = $this->normalizeSVNPath($external_path);
      $this->externalBaseURI = $pre.$external_path;

      $internal_path = rtrim($repository->getLocalPath(), '/');
      $internal_path = $this->normalizeSVNPath($internal_path);
      $this->internalBaseURI = $pre.$internal_path;
    }

    return (string)$uri;
  }

  private function makeExternalURI($uri) {
    if ($this->isProxying) {
      return $uri;
    }

    $internal = $this->internalBaseURI;
    $external = $this->externalBaseURI;

    if (strncmp($uri, $internal, strlen($internal)) === 0) {
      $uri = $external.substr($uri, strlen($internal));
    }

    return $uri;
  }

  private function normalizeSVNPath($path) {
    // Subversion normalizes redundant slashes internally, so normalize them
    // here as well to make sure things match up.
    $path = preg_replace('(/+)', '/', $path);

    return $path;
  }

  protected function raiseWrongVCSException(
    PhabricatorRepository $repository) {
    throw new Exception(
      pht(
        'This repository ("%s") is not a Subversion repository. Use "%s" to '.
        'interact with this repository.',
        $repository->getDisplayName(),
        $repository->getVersionControlSystem()));
  }

}
