diff --git a/scripts/repository/commit_hook.php b/scripts/repository/commit_hook.php index 405c86c196..dabf9320a9 100755 --- a/scripts/repository/commit_hook.php +++ b/scripts/repository/commit_hook.php @@ -1,128 +1,139 @@ #!/usr/bin/env php ')); } $engine = new DiffusionCommitHookEngine(); $repository = id(new PhabricatorRepositoryQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withCallsigns(array($argv[1])) ->executeOne(); if (!$repository) { throw new Exception(pht('No such repository "%s"!', $callsign)); } if (!$repository->isHosted()) { // This should be redundant, but double check just in case. throw new Exception(pht('Repository "%s" is not hosted!', $callsign)); } $engine->setRepository($repository); // Figure out which user is writing the commit. if ($repository->isGit() || $repository->isHg()) { - $username = getenv('PHABRICATOR_USER'); + $username = getenv(DiffusionCommitHookEngine::ENV_USER); if (!strlen($username)) { - throw new Exception(pht('usage: PHABRICATOR_USER should be defined!')); + throw new Exception( + pht('usage: %s should be defined!', DiffusionCommitHookEngine::ENV_USER)); } // TODO: If this is a Mercurial repository, the hook we're responding to // is available in $argv[2]. It's unclear if we actually need this, or if // we can block all actions we care about with just pretxnchangegroup. } else if ($repository->isSVN()) { // NOTE: In Subversion, the entire environment gets wiped so we can't read - // PHABRICATOR_USER. Instead, we've set "--tunnel-user" to specify the - // correct user; read this user out of the commit log. + // DiffusionCommitHookEngine::ENV_USER. Instead, we've set "--tunnel-user" to + // specify the correct user; read this user out of the commit log. if ($argc < 4) { throw new Exception(pht('usage: commit-hook ')); } $svn_repo = $argv[2]; $svn_txn = $argv[3]; list($username) = execx('svnlook author -t %s %s', $svn_txn, $svn_repo); $username = rtrim($username, "\n"); $engine->setSubversionTransactionInfo($svn_txn, $svn_repo); } else { throw new Exception(pht('Unknown repository type.')); } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { throw new Exception(pht('No such user "%s"!', $username)); } $engine->setViewer($user); // Read stdin for the hook engine. if ($repository->isHg()) { // Mercurial leaves stdin open, so we can't just read it until EOF. $stdin = ''; } else { // Git and Subversion write data into stdin and then close it. Read the // data. $stdin = @file_get_contents('php://stdin'); if ($stdin === false) { throw new Exception(pht('Failed to read stdin!')); } } $engine->setStdin($stdin); +$remote_address = getenv(DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS); +if (strlen($remote_address)) { + $engine->setRemoteAddress($remote_address); +} + +$remote_protocol = getenv(DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL); +if (strlen($remote_protocol)) { + $engine->setRemoteProtocol($remote_protocol); +} + try { $err = $engine->execute(); } catch (DiffusionCommitHookRejectException $ex) { $console = PhutilConsole::getConsole(); if (PhabricatorEnv::getEnvConfig('phabricator.serious-business')) { $preamble = pht('*** PUSH REJECTED BY COMMIT HOOK ***'); } else { $preamble = pht(<<writeErr("%s\n\n", $preamble); $console->writeErr("%s\n\n", $ex->getMessage()); $err = 1; } exit($err); diff --git a/src/applications/diffusion/controller/DiffusionPushLogListController.php b/src/applications/diffusion/controller/DiffusionPushLogListController.php index 3caee3c79a..e0067997a7 100644 --- a/src/applications/diffusion/controller/DiffusionPushLogListController.php +++ b/src/applications/diffusion/controller/DiffusionPushLogListController.php @@ -1,96 +1,104 @@ queryKey = idx($data, 'queryKey'); } public function processRequest() { $request = $this->getRequest(); $controller = id(new PhabricatorApplicationSearchController($request)) ->setQueryKey($this->queryKey) ->setSearchEngine(new PhabricatorRepositoryPushLogSearchEngine()) ->setNavigation($this->buildSideNavView()); return $this->delegateToController($controller); } public function renderResultsList( array $logs, PhabricatorSavedQuery $query) { $viewer = $this->getRequest()->getUser(); $this->loadHandles(mpull($logs, 'getPusherPHID')); $rows = array(); foreach ($logs as $log) { $callsign = $log->getRepository()->getCallsign(); $rows[] = array( phutil_tag( 'a', array( 'href' => $this->getApplicationURI($callsign.'/'), ), $callsign), $this->getHandle($log->getPusherPHID())->renderLink(), + $log->getRemoteAddress() + ? long2ip($log->getRemoteAddress()) + : null, + $log->getRemoteProtocol(), $log->getRefType(), $log->getRefName(), $log->getRefOldShort(), $log->getRefNewShort(), phabricator_datetime($log->getEpoch(), $viewer), ); } $table = id(new AphrontTableView($rows)) ->setHeaders( array( pht('Repository'), pht('Pusher'), + pht('From'), + pht('Via'), pht('Type'), pht('Name'), pht('Old'), pht('New'), pht('Date'), )) ->setColumnClasses( array( + '', + '', '', '', '', 'wide', 'n', 'n', 'date', )); $box = id(new PHUIBoxView()) ->addMargin(PHUI::MARGIN_LARGE) ->appendChild($table); return $box; } public function buildSideNavView($for_app = false) { $viewer = $this->getRequest()->getUser(); $nav = new AphrontSideNavFilterView(); $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); id(new PhabricatorRepositoryPushLogSearchEngine()) ->setViewer($viewer) ->addNavigationItems($nav->getMenu()); $nav->selectFilter(null); return $nav; } } diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index 6d72184216..ea5a8c8a68 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -1,561 +1,568 @@ getHTTPHeader('Content-Type'); $user_agent = idx($_SERVER, 'HTTP_USER_AGENT'); $vcs = null; if ($request->getExists('service')) { $service = $request->getStr('service'); // We get this initially for `info/refs`. // Git also gives us a User-Agent like "git/1.8.2.3". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if (strncmp($user_agent, "git/", 4) === 0) { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-upload-pack-request') { // We get this for `git-upload-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-receive-pack-request') { // We get this for `git-receive-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($request->getExists('cmd')) { // Mercurial also sends an Accept header like // "application/mercurial-0.1", and a User-Agent like // "mercurial/proto-1.0". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; } else { // Subversion also sends an initial OPTIONS request (vs GET/POST), and // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2) // serf/1.3.2". $dav = $request->getHTTPHeader('DAV'); $dav = new PhutilURI($dav); if ($dav->getDomain() === 'subversion.tigris.org') { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN; } } return $vcs; } private static function getCallsign(AphrontRequest $request) { $uri = $request->getRequestURI(); $regex = '@^/diffusion/(?P[A-Z]+)(/|$)@'; $matches = null; if (!preg_match($regex, (string)$uri, $matches)) { return null; } return $matches['callsign']; } public function processRequest() { $request = $this->getRequest(); $callsign = self::getCallsign($request); // If authentication credentials have been provided, try to find a user // that actually matches those credentials. if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); $viewer = $this->authenticateHTTPRepositoryUser($username, $password); if (!$viewer) { return new PhabricatorVCSResponse( 403, pht('Invalid credentials.')); } } else { // User hasn't provided credentials, which means we count them as // being "not logged in". $viewer = new PhabricatorUser(); } $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public'); $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); if (!$allow_public) { if (!$viewer->isLoggedIn()) { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access repositories.')); } else { return new PhabricatorVCSResponse( 403, pht('Public and authenticated HTTP access are both forbidden.')); } } } try { $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withCallsigns(array($callsign)) ->executeOne(); if (!$repository) { return new PhabricatorVCSResponse( 404, pht('No such repository exists.')); } } catch (PhabricatorPolicyException $ex) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to access this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'This repository requires authentication, which is forbidden '. 'over HTTP.')); } } } if (!$repository->isTracked()) { return new PhabricatorVCSResponse( 403, pht('This repository is inactive.')); } $is_push = !$this->isReadOnlyRequest($repository); switch ($repository->getServeOverHTTP()) { case PhabricatorRepository::SERVE_READONLY: if ($is_push) { return new PhabricatorVCSResponse( 403, pht('This repository is read-only over HTTP.')); } break; case PhabricatorRepository::SERVE_READWRITE: if ($is_push) { $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionCapabilityPush::CAPABILITY); if (!$can_push) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to push to this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to push to this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'Pushing to this repository requires authentication, '. 'which is forbidden over HTTP.')); } } } } break; case PhabricatorRepository::SERVE_OFF: default: return new PhabricatorVCSResponse( 403, pht('This repository is not available over HTTP.')); } $vcs_type = $repository->getVersionControlSystem(); $req_type = $this->isVCSRequest($request); if ($vcs_type != $req_type) { switch ($req_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = new PhabricatorVCSResponse( 500, pht('This is not a Git repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = new PhabricatorVCSResponse( 500, pht('This is not a Mercurial repository.')); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht('This is not a Subversion repository.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown request type.')); break; } } else { switch ($vcs_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = $this->serveGitRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->serveMercurialRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht( 'Phabricator does not support HTTP access to Subversion '. 'repositories.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown version control system.')); break; } } $code = $result->getHTTPResponseCode(); if ($is_push && ($code == 200)) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); unset($unguarded); } return $result; } private function isReadOnlyRequest( PhabricatorRepository $repository) { $request = $this->getRequest(); $method = $_SERVER['REQUEST_METHOD']; // TODO: This implementation is safe by default, but very incomplete. switch ($repository->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $service = $request->getStr('service'); $path = $this->getRequestDirectoryPath(); // NOTE: Service names are the reverse of what you might expect, as they // are from the point of view of the server. The main read service is // "git-upload-pack", and the main write service is "git-receive-pack". if ($method == 'GET' && $path == '/info/refs' && $service == 'git-upload-pack') { return true; } if ($path == '/git-upload-pack') { return true; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $cmd = $request->getStr('cmd'); if ($cmd == 'batch') { $cmds = idx($this->getMercurialArguments(), 'cmds'); return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); } return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: break; } return false; } /** * @phutil-external-symbol class PhabricatorStartup */ private function serveGitRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $request_path = $this->getRequestDirectoryPath(); $repository_root = $repository->getLocalPath(); // Rebuild the query string to strip `__magic__` parameters and prevent // issues where we might interpret inputs like "service=read&service=write" // differently than the server does and pass it an unsafe command. // NOTE: This does not use getPassthroughRequestParameters() because // that code is HTTP-method agnostic and will encode POST data. $query_data = $_GET; foreach ($query_data as $key => $value) { if (!strncmp($key, '__', 2)) { unset($query_data[$key]); } } $query_string = http_build_query($query_data, '', '&'); // We're about to wipe out PATH with the rest of the environment, so // resolve the binary first. $bin = Filesystem::resolveBinary('git-http-backend'); if (!$bin) { throw new Exception("Unable to find `git-http-backend` in PATH!"); } $env = array( 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 'QUERY_STRING' => $query_string, 'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'), 'HTTP_CONTENT_ENCODING' => $request->getHTTPHeader('Content-Encoding'), 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 'GIT_PROJECT_ROOT' => $repository_root, 'GIT_HTTP_EXPORT_ALL' => '1', 'PATH_INFO' => $request_path, 'REMOTE_USER' => $viewer->getUsername(), - 'PHABRICATOR_USER' => $viewer->getUsername(), // TODO: Set these correctly. // GIT_COMMITTER_NAME // GIT_COMMITTER_EMAIL - ); + ) + $this->getCommonEnvironment($viewer); $input = PhabricatorStartup::getRawInput(); $command = csprintf('%s', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->write($input) ->resolve(); if ($err) { if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) { // Ignore the error if the response passes this special check for // validity. $err = 0; } } if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } return id(new DiffusionGitResponse())->setGitData($stdout); } private function getRequestDirectoryPath() { $request = $this->getRequest(); $request_path = $request->getRequestURI()->getPath(); return preg_replace('@^/diffusion/[A-Z]+@', '', $request_path); } private function authenticateHTTPRepositoryUser( $username, PhutilOpaqueEnvelope $password) { if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) { // No HTTP auth permitted. return null; } if (!strlen($username)) { // No username. return null; } if (!strlen($password->openEnvelope())) { // No password. return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { // Username doesn't match anything. return null; } if (!$user->isUserActivated()) { // User is not activated. return null; } $password_entry = id(new PhabricatorRepositoryVCSPassword()) ->loadOneWhere('userPHID = %s', $user->getPHID()); if (!$password_entry) { // User doesn't have a password set. return null; } if (!$password_entry->comparePassword($password, $user)) { // Password doesn't match. return null; } return $user; } private function serveMercurialRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $bin = Filesystem::resolveBinary('hg'); if (!$bin) { throw new Exception("Unable to find `hg` in PATH!"); } - $env = array( - 'PHABRICATOR_USER' => $viewer->getUsername(), - ); + $env = $this->getCommonEnvironment($viewer); $input = PhabricatorStartup::getRawInput(); $cmd = $request->getStr('cmd'); $args = $this->getMercurialArguments(); $args = $this->formatMercurialArguments($cmd, $args); if (strlen($input)) { $input = strlen($input)."\n".$input."0\n"; } $command = csprintf('%s serve --stdio', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->setCWD($repository->getLocalPath()) ->write("{$cmd}\n{$args}{$input}") ->resolve(); if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } if ($cmd == 'getbundle' || $cmd == 'changegroup' || $cmd == 'changegroupsubset') { // We're not completely sure that "changegroup" and "changegroupsubset" // actually work, they're for very old Mercurial. $body = gzcompress($stdout); } else if ($cmd == 'unbundle') { // This includes diagnostic information and anything echoed by commit // hooks. We ignore `stdout` since it just has protocol garbage, and // substitute `stderr`. $body = strlen($stderr)."\n".$stderr; } else { list($length, $body) = explode("\n", $stdout, 2); } return id(new DiffusionMercurialResponse())->setContent($body); } private function getMercurialArguments() { // Mercurial sends arguments in HTTP headers. "Why?", you might wonder, // "Why would you do this?". $args_raw = array(); for ($ii = 1; ; $ii++) { $header = 'HTTP_X_HGARG_'.$ii; if (!array_key_exists($header, $_SERVER)) { break; } $args_raw[] = $_SERVER[$header]; } $args_raw = implode('', $args_raw); return id(new PhutilQueryStringParser()) ->parseQueryString($args_raw); } private function formatMercurialArguments($command, array $arguments) { $spec = DiffusionMercurialWireProtocol::getCommandArgs($command); $out = array(); // Mercurial takes normal arguments like this: // // name // value $has_star = false; foreach ($spec as $arg_key) { if ($arg_key == '*') { $has_star = true; continue; } if (isset($arguments[$arg_key])) { $value = $arguments[$arg_key]; $size = strlen($value); $out[] = "{$arg_key} {$size}\n{$value}"; unset($arguments[$arg_key]); } } if ($has_star) { // Mercurial takes arguments for variable argument lists roughly like // this: // // * // argname1 // argvalue1 // argname2 // argvalue2 $count = count($arguments); $out[] = "* {$count}\n"; foreach ($arguments as $key => $value) { if (in_array($key, $spec)) { // We already added this argument above, so skip it. continue; } $size = strlen($value); $out[] = "{$key} {$size}\n{$value}"; } } return implode('', $out); } private function isValidGitShallowCloneResponse($stdout, $stderr) { // If you execute `git clone --depth N ...`, git sends a request which // `git-http-backend` responds to by emitting valid output and then exiting // with a failure code and an error message. If we ignore this error, // everything works. // This is a pretty funky fix: it would be nice to more precisely detect // that a request is a `--depth N` clone request, but we don't have any code // to decode protocol frames yet. Instead, look for reasonable evidence // in the error and output that we're looking at a `--depth` clone. // For evidence this isn't completely crazy, see: // https://github.com/schacon/grack/pull/7 $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m'; $stderr_regexp = '(The remote end hung up unexpectedly)'; $has_pack = preg_match($stdout_regexp, $stdout); $is_hangup = preg_match($stderr_regexp, $stderr); return $has_pack && $is_hangup; } + private function getCommonEnvironment(PhabricatorUser $viewer) { + $remote_addr = $this->getRequest()->getRemoteAddr(); + + return array( + DiffusionCommitHookEngine::ENV_USER => $viewer->getUsername(), + DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS => $remote_addr, + DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'http', + ); + } + } diff --git a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php index 792c334924..23a334c1c4 100644 --- a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php +++ b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php @@ -1,305 +1,336 @@ remoteProtocol = $remote_protocol; + return $this; + } + + public function getRemoteProtocol() { + return $this->remoteProtocol; + } + public function setRemoteAddress($remote_address) { + $this->remoteAddress = $remote_address; + return $this; + } + + public function getRemoteAddress() { + return $this->remoteAddress; + } public function setSubversionTransactionInfo($transaction, $repository) { $this->subversionTransaction = $transaction; $this->subversionRepository = $repository; return $this; } public function setStdin($stdin) { $this->stdin = $stdin; return $this; } public function getStdin() { return $this->stdin; } public function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function getRepository() { return $this->repository; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function execute() { $type = $this->getRepository()->getVersionControlSystem(); switch ($type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $err = $this->executeGitHook(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $err = $this->executeSubversionHook(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $err = $this->executeMercurialHook(); break; default: throw new Exception(pht('Unsupported repository type "%s"!', $type)); } return $err; } /** * @task git */ private function executeGitHook() { $updates = $this->parseGitUpdates($this->getStdin()); $this->rejectGitDangerousChanges($updates); // TODO: Do cheap checks: non-ff commits, mutating refs without access, // creating or deleting things you can't touch. We can do all non-content // checks here. $updates = $this->findGitNewCommits($updates); // TODO: Now, do content checks. // TODO: Generalize this; just getting some data in the database for now. $transaction_key = PhabricatorHash::digestForIndex( Filesystem::readRandomBytes(64)); + // If whatever we have here isn't a valid IPv4 address, just store `null`. + // Older versions of PHP return `-1` on failure instead of `false`. + $remote_address = $this->getRemoteAddress(); + $remote_address = max(0, ip2long($remote_address)); + $remote_address = nonempty($remote_address, null); + + $remote_protocol = $this->getRemoteProtocol(); + $logs = array(); foreach ($updates as $update) { $log = PhabricatorRepositoryPushLog::initializeNewLog($this->getViewer()) ->setRepositoryPHID($this->getRepository()->getPHID()) ->setEpoch(time()) - ->setRemoteAddress(null) // TODO: Populate this where possible. - ->setRemoteProtocol(null) // TODO: Populate this where possible. + ->setRemoteAddress($remote_address) + ->setRemoteProtocol($remote_protocol) ->setTransactionKey($transaction_key) ->setRefType($update['type']) ->setRefNameHash(PhabricatorHash::digestForIndex($update['ref'])) ->setRefNameRaw($update['ref']) ->setRefNameEncoding(phutil_is_utf8($update['ref']) ? 'utf8' : null) ->setRefOld($update['old']) ->setRefNew($update['new']) ->setMergeBase(idx($update, 'merge-base')) ->setRejectCode(PhabricatorRepositoryPushLog::REJECT_ACCEPT) ->setRejectDetails(null); $flags = 0; if ($update['operation'] == 'create') { $flags = $flags | PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; } else if ($update['operation'] == 'delete') { $flags = $flags | PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; } else { // TODO: This isn't correct; these might be APPEND or REWRITE, and // if they're REWRITE they might be DANGEROUS. Fix this when this // gets generalized. $flags = $flags | PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; } $log->setChangeFlags($flags); $logs[] = $log; } foreach ($logs as $log) { $log->save(); } return 0; } /** * @task git */ private function parseGitUpdates($stdin) { $updates = array(); $lines = phutil_split_lines($stdin, $retain_endings = false); foreach ($lines as $line) { $parts = explode(' ', $line, 3); if (count($parts) != 3) { throw new Exception(pht('Expected "old new ref", got "%s".', $line)); } $update = array( 'old' => $parts[0], 'old.short' => substr($parts[0], 0, 8), 'new' => $parts[1], 'new.short' => substr($parts[1], 0, 8), 'ref' => $parts[2], ); if (preg_match('(^refs/heads/)', $update['ref'])) { $update['type'] = 'branch'; $update['ref.short'] = substr($update['ref'], strlen('refs/heads/')); } else if (preg_match('(^refs/tags/)', $update['ref'])) { $update['type'] = 'tag'; $update['ref.short'] = substr($update['ref'], strlen('refs/tags/')); } else { $update['type'] = 'unknown'; } $updates[] = $update; } $updates = $this->findGitMergeBases($updates); return $updates; } /** * @task git */ private function findGitMergeBases(array $updates) { $empty = str_repeat('0', 40); $futures = array(); foreach ($updates as $key => $update) { // Updates are in the form: // // // // If the old hash is "00000...", the ref is being created (either a new // branch, or a new tag). If the new hash is "00000...", the ref is being // deleted. If both are nonempty, the ref is being updated. For updates, // we'll figure out the `merge-base` of the old and new objects here. This // lets us reject non-FF changes cheaply; later, we'll figure out exactly // which commits are new. if ($update['old'] == $empty) { $updates[$key]['operation'] = 'create'; } else if ($update['new'] == $empty) { $updates[$key]['operation'] = 'delete'; } else { $updates[$key]['operation'] = 'update'; $futures[$key] = $this->getRepository()->getLocalCommandFuture( 'merge-base %s %s', $update['old'], $update['new']); } } foreach (Futures($futures)->limit(8) as $key => $future) { list($stdout) = $future->resolvex(); $updates[$key]['merge-base'] = rtrim($stdout, "\n"); } return $updates; } private function findGitNewCommits(array $updates) { $futures = array(); foreach ($updates as $key => $update) { if ($update['operation'] == 'delete') { // Deleting a branch or tag can never create any new commits. continue; } // NOTE: This piece of magic finds all new commits, by walking backward // from the new value to the value of *any* existing ref in the // repository. Particularly, this will cover the cases of a new branch, a // completely moved tag, etc. $futures[$key] = $this->getRepository()->getLocalCommandFuture( 'log --format=%s %s --not --all', '%H', $update['new']); } foreach (Futures($futures)->limit(8) as $key => $future) { list($stdout) = $future->resolvex(); $commits = phutil_split_lines($stdout, $retain_newlines = false); $updates[$key]['commits'] = $commits; } return $updates; } private function rejectGitDangerousChanges(array $updates) { $repository = $this->getRepository(); if ($repository->shouldAllowDangerousChanges()) { return; } foreach ($updates as $update) { if ($update['type'] != 'branch') { // For now, we don't consider deleting or moving tags to be a // "dangerous" update. It's way harder to get wrong and should be easy // to recover from once we have better logging. continue; } if ($update['operation'] == 'create') { // Creating a branch is never dangerous. continue; } if ($update['operation'] == 'update') { if ($update['old'] == $update['merge-base']) { // This is a fast-forward update to an existing branch. // These are safe. continue; } } // We either have a branch deletion or a non fast-forward branch update. // Format a message and reject the push. if ($update['operation'] == 'delete') { $message = pht( "DANGEROUS CHANGE: The change you're attempting to push deletes ". "the branch '%s'.", $update['ref.short']); } else { $message = pht( "DANGEROUS CHANGE: The change you're attempting to push updates ". "the branch '%s' from '%s' to '%s', but this is not a fast-forward. ". "Pushes which rewrite published branch history are dangerous.", $update['ref.short'], $update['old.short'], $update['new.short']); } $boilerplate = pht( "Dangerous change protection is enabled for this repository.\n". "Edit the repository configuration before making dangerous changes."); $message = $message."\n".$boilerplate; throw new DiffusionCommitHookRejectException($message); } } private function executeSubversionHook() { // TODO: Do useful things here, too. return 0; } private function executeMercurialHook() { // TODO: Here, too, useful things should be done. return 0; } } diff --git a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php index be1a1d55fd..7b6fe5af9a 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php @@ -1,129 +1,139 @@ repository) { throw new Exception("Call loadRepository() before getRepository()!"); } return $this->repository; } public function getArgs() { return $this->args; } public function getEnvironment() { - return array( - 'PHABRICATOR_USER' => $this->getUser()->getUsername(), + $env = array( + DiffusionCommitHookEngine::ENV_USER => $this->getUser()->getUsername(), + DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'ssh', ); + + $ssh_client = getenv('SSH_CLIENT'); + if ($ssh_client) { + // This has the format " ". Grab the IP. + $remote_address = head(explode(' ', $ssh_client)); + $env[DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS] = $remote_address; + } + + return $env; } abstract protected function executeRepositoryOperations(); protected function writeError($message) { $this->getErrorChannel()->write($message); return $this; } final public function execute(PhutilArgumentParser $args) { $this->args = $args; try { return $this->executeRepositoryOperations(); } catch (Exception $ex) { $this->writeError(get_class($ex).': '.$ex->getMessage()); return 1; } } protected function loadRepository($path) { $viewer = $this->getUser(); $regex = '@^/?diffusion/(?P[A-Z]+)(?:/|$)@'; $matches = null; if (!preg_match($regex, $path, $matches)) { throw new Exception( pht( 'Unrecognized repository path "%s". Expected a path like '. '"%s".', $path, "/diffusion/X/")); } $callsign = $matches[1]; $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withCallsigns(array($callsign)) ->executeOne(); if (!$repository) { throw new Exception( pht('No repository "%s" exists!', $callsign)); } switch ($repository->getServeOverSSH()) { case PhabricatorRepository::SERVE_READONLY: case PhabricatorRepository::SERVE_READWRITE: // If we have read or read/write access, proceed for now. We will // check write access when the user actually issues a write command. break; case PhabricatorRepository::SERVE_OFF: default: throw new Exception( pht('This repository is not available over SSH.')); } $this->repository = $repository; return $repository; } protected function requireWriteAccess($protocol_command = null) { if ($this->hasWriteAccess === true) { return; } $repository = $this->getRepository(); $viewer = $this->getUser(); switch ($repository->getServeOverSSH()) { case PhabricatorRepository::SERVE_READONLY: if ($protocol_command !== null) { throw new Exception( pht( 'This repository is read-only over SSH (tried to execute '. 'protocol command "%s").', $protocol_command)); } else { throw new Exception( pht('This repository is read-only over SSH.')); } break; case PhabricatorRepository::SERVE_READWRITE: $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionCapabilityPush::CAPABILITY); if (!$can_push) { throw new Exception( pht('You do not have permission to push to this repository.')); } break; case PhabricatorRepository::SERVE_OFF: default: // This shouldn't be reachable because we don't get this far if the // repository isn't enabled, but kick them out anyway. throw new Exception( pht('This repository is not available over SSH.')); } $this->hasWriteAccess = true; return $this->hasWriteAccess; } }