diff --git a/src/error/PhutilErrorHandler.php b/src/error/PhutilErrorHandler.php index 1434bb43..661e3164 100644 --- a/src/error/PhutilErrorHandler.php +++ b/src/error/PhutilErrorHandler.php @@ -1,612 +1,611 @@ getPrevious(); } if (method_exists($ex, 'getPreviousException')) { return $ex->getPreviousException(); } return null; } /** * Find the most deeply nested exception from a possibly-nested exception. * * @param Exception|Throwable A possibly-nested exception. * @return Exception|Throwable Deepest exception in the nest. * @task exutil */ public static function getRootException($ex) { $root = $ex; while (self::getPreviousException($root)) { $root = self::getPreviousException($root); } return $root; } /* -( Trapping Errors )---------------------------------------------------- */ /** * Adds an error trap. Normally you should not invoke this directly; * @{class:PhutilErrorTrap} registers itself on construction. * * @param PhutilErrorTrap Trap to add. * @return void * @task trap */ public static function addErrorTrap(PhutilErrorTrap $trap) { $key = $trap->getTrapKey(); self::$traps[$key] = $trap; } /** * Removes an error trap. Normally you should not invoke this directly; * @{class:PhutilErrorTrap} deregisters itself on destruction. * * @param PhutilErrorTrap Trap to remove. * @return void * @task trap */ public static function removeErrorTrap(PhutilErrorTrap $trap) { $key = $trap->getTrapKey(); unset(self::$traps[$key]); } /* -( Internals )---------------------------------------------------------- */ /** * Determine if PhutilErrorHandler has been initialized. * * @return bool True if initialized. * @task internal */ public static function hasInitialized() { return self::$initialized; } /** * Handles PHP errors and dispatches them forward. This is a callback for * ##set_error_handler()##. You should not call this function directly; use * @{function:phlog} to print debugging messages or ##trigger_error()## to * trigger PHP errors. * * This handler converts E_RECOVERABLE_ERROR messages from violated typehints * into @{class:InvalidArgumentException}s. * * This handler converts other E_RECOVERABLE_ERRORs into * @{class:RuntimeException}s. * * This handler converts E_NOTICE messages from uses of undefined variables * into @{class:RuntimeException}s. * * @param int Error code. * @param string Error message. * @param string File where the error occurred. * @param int Line on which the error occurred. * @param wild Error context information. * @return void * @task internal */ public static function handleError($num, $str, $file, $line, $ctx = null) { - foreach (self::$traps as $trap) { $trap->addError($num, $str, $file, $line); } if ((error_reporting() & $num) == 0) { // Respect the use of "@" to silence warnings: if this error was // emitted from a context where "@" was in effect, the // value returned by error_reporting() will be 0. This is the // recommended way to check for this, see set_error_handler() docs // on php.net. return false; } // See T13499. If this is a user error arising from "trigger_error()" or // similar, route it through normal error handling: this is probably the // best match to authorial intent, since the code could choose to throw // an exception instead if it wanted that behavior. Phabricator does not // use "trigger_error()" so we never normally expect to reach this // block in first-party code. if (($num === E_USER_ERROR) || ($num === E_USER_WARNING) || ($num === E_USER_NOTICE)) { $trace = debug_backtrace(); array_shift($trace); self::dispatchErrorMessage( self::ERROR, $str, array( 'file' => $file, 'line' => $line, 'error_code' => $num, 'trace' => $trace, )); return; } // Convert typehint failures into exceptions. if (preg_match('/^Argument (\d+) passed to (\S+) must be/', $str)) { throw new InvalidArgumentException($str); } // Convert other E_RECOVERABLE_ERRORs into generic runtime exceptions. if ($num == E_RECOVERABLE_ERROR) { throw new RuntimeException($str); } // Convert uses of undefined variables into exceptions. if (preg_match('/^Undefined variable: /', $str)) { throw new RuntimeException($str); } // Convert uses of undefined properties into exceptions. if (preg_match('/^Undefined property: /', $str)) { throw new RuntimeException($str); } // Convert undefined constants into exceptions. Usually this means there // is a missing `$` and the program is horribly broken. if (preg_match('/^Use of undefined constant /', $str)) { throw new RuntimeException($str); } // Convert undefined indexes into exceptions. if (preg_match('/^Undefined index: /', $str)) { throw new RuntimeException($str); } // Convert undefined offsets into exceptions. if (preg_match('/^Undefined offset: /', $str)) { throw new RuntimeException($str); } // See T13499. Convert all other runtime errors not handled in a more // specific way into runtime exceptions. throw new RuntimeException($str); } /** * Handles PHP exceptions and dispatches them forward. This is a callback for * ##set_exception_handler()##. You should not call this function directly; * to print exceptions, pass the exception object to @{function:phlog}. * * @param Exception|Throwable Uncaught exception object. * @return void * @task internal */ public static function handleException($ex) { self::dispatchErrorMessage( self::EXCEPTION, $ex, array( 'file' => $ex->getFile(), 'line' => $ex->getLine(), 'trace' => self::getExceptionTrace($ex), 'catch_trace' => debug_backtrace(), )); // Normally, PHP exits with code 255 after an uncaught exception is thrown. // However, if we install an exception handler (as we have here), it exits // with code 0 instead. Script execution terminates after this function // exits in either case, so exit explicitly with the correct exit code. exit(255); } /** * Output a stacktrace to the PHP error log. * * @param trace A stacktrace, e.g. from debug_backtrace(); * @return void * @task internal */ public static function outputStacktrace($trace) { $lines = explode("\n", self::formatStacktrace($trace)); foreach ($lines as $line) { error_log($line); } } /** * Format a stacktrace for output. * * @param trace A stacktrace, e.g. from debug_backtrace(); * @return string Human-readable trace. * @task internal */ public static function formatStacktrace($trace) { $result = array(); $libinfo = self::getLibraryVersions(); if ($libinfo) { foreach ($libinfo as $key => $dict) { $info = array(); foreach ($dict as $dkey => $dval) { $info[] = $dkey.'='.$dval; } $libinfo[$key] = $key.'('.implode(', ', $info).')'; } $result[] = implode(', ', $libinfo); } foreach ($trace as $key => $entry) { $line = ' #'.$key.' '; if (!empty($entry['xid'])) { if ($entry['xid'] != 1) { $line .= '<#'.$entry['xid'].'> '; } } if (isset($entry['class'])) { $line .= $entry['class'].'::'; } $line .= idx($entry, 'function', ''); if (isset($entry['args'])) { $args = array(); foreach ($entry['args'] as $arg) { // NOTE: Print out object types, not values. Values sometimes contain // sensitive information and are usually not particularly helpful // for debugging. $type = (gettype($arg) == 'object') ? get_class($arg) : gettype($arg); $args[] = $type; } $line .= '('.implode(', ', $args).')'; } if (isset($entry['file'])) { $file = self::adjustFilePath($entry['file']); $line .= ' called at ['.$file.':'.$entry['line'].']'; } $result[] = $line; } return implode("\n", $result); } /** * All different types of error messages come here before they are * dispatched to the listener; this method also prints them to the PHP error * log. * * @param const Event type constant. * @param wild Event value. * @param dict Event metadata. * @return void * @task internal */ public static function dispatchErrorMessage($event, $value, $metadata) { - $timestamp = strftime('%Y-%m-%d %H:%M:%S'); + $timestamp = date('Y-m-d H:i:s'); switch ($event) { case self::ERROR: $default_message = sprintf( '[%s] ERROR %d: %s at [%s:%d]', $timestamp, $metadata['error_code'], $value, $metadata['file'], $metadata['line']); $metadata['default_message'] = $default_message; error_log($default_message); self::outputStacktrace($metadata['trace']); break; case self::EXCEPTION: $messages = array(); $current = $value; do { $messages[] = '('.get_class($current).') '.$current->getMessage(); } while ($current = self::getPreviousException($current)); $messages = implode(' {>} ', $messages); if (strlen($messages) > 4096) { $messages = substr($messages, 0, 4096).'...'; } $default_message = sprintf( '[%s] EXCEPTION: %s at [%s:%d]', $timestamp, $messages, self::adjustFilePath(self::getRootException($value)->getFile()), self::getRootException($value)->getLine()); $metadata['default_message'] = $default_message; error_log($default_message); self::outputStacktrace($metadata['trace']); break; case self::PHLOG: $default_message = sprintf( '[%s] PHLOG: %s at [%s:%d]', $timestamp, PhutilReadableSerializer::printShort($value), $metadata['file'], $metadata['line']); $metadata['default_message'] = $default_message; error_log($default_message); break; default: error_log(pht('Unknown event %s', $event)); break; } if (self::$errorListener) { static $handling_error; if ($handling_error) { error_log( 'Error handler was reentered, some errors were not passed to the '. 'listener.'); return; } $handling_error = true; call_user_func(self::$errorListener, $event, $value, $metadata); $handling_error = false; } } public static function adjustFilePath($path) { // Compute known library locations so we can emit relative paths if the // file resides inside a known library. This is a little cleaner to read, // and limits the number of false positives we get about full path // disclosure via HackerOne. $bootloader = PhutilBootloader::getInstance(); $libraries = $bootloader->getAllLibraries(); $roots = array(); foreach ($libraries as $library) { $root = $bootloader->getLibraryRoot($library); // For these libraries, the effective root is one level up. switch ($library) { case 'arcanist': case 'phabricator': $root = dirname($root); break; } if (!strncmp($root, $path, strlen($root))) { return '<'.$library.'>'.substr($path, strlen($root)); } } return $path; } public static function getLibraryVersions() { $libinfo = array(); $bootloader = PhutilBootloader::getInstance(); foreach ($bootloader->getAllLibraries() as $library) { $root = phutil_get_library_root($library); $try_paths = array( $root, dirname($root), ); $libinfo[$library] = array(); $get_refs = array('master'); foreach ($try_paths as $try_path) { // Try to read what the HEAD of the repository is pointed at. This is // normally the name of a branch ("ref"). $try_file = $try_path.'/.git/HEAD'; if (@file_exists($try_file)) { $head = @file_get_contents($try_file); $matches = null; if (preg_match('(^ref: refs/heads/(.*)$)', trim($head), $matches)) { $libinfo[$library]['head'] = trim($matches[1]); $get_refs[] = trim($matches[1]); } else { $libinfo[$library]['head'] = trim($head); } break; } } // Try to read which commit relevant branch heads are at. foreach (array_unique($get_refs) as $ref) { foreach ($try_paths as $try_path) { $try_file = $try_path.'/.git/refs/heads/'.$ref; if (@file_exists($try_file)) { $hash = @file_get_contents($try_file); if ($hash) { $libinfo[$library]['ref.'.$ref] = substr(trim($hash), 0, 12); break; } } } } // Look for extension files. $custom = @scandir($root.'/extensions/'); if ($custom) { $count = 0; foreach ($custom as $custom_path) { if (preg_match('/\.php$/', $custom_path)) { $count++; } } if ($count) { $libinfo[$library]['custom'] = $count; } } } ksort($libinfo); return $libinfo; } /** * Get a full trace across all proxied and aggregated exceptions. * * This attempts to build a set of stack frames which completely represent * all of the places an exception came from, even if it came from multiple * origins and has been aggregated or proxied. * * @param Exception|Throwable Exception to retrieve a trace for. * @return list List of stack frames. */ public static function getExceptionTrace($ex) { $id = 1; // Keep track of discovered exceptions which we need to build traces for. $stack = array( array($id, $ex), ); $frames = array(); while ($info = array_shift($stack)) { list($xid, $ex) = $info; // We're going from top-level exception down in bredth-first order, but // want to build a trace in approximately standard order (deepest part of // the call stack to most shallow) so we need to reverse each list of // frames and then reverse everything at the end. $ex_frames = array_reverse($ex->getTrace()); $ex_frames = array_values($ex_frames); $last_key = (count($ex_frames) - 1); foreach ($ex_frames as $frame_key => $frame) { $frame['xid'] = $xid; // If this is a child/previous exception and we're on the deepest frame // and missing file/line data, fill it in from the exception itself. if ($xid > 1 && ($frame_key == $last_key)) { if (empty($frame['file'])) { $frame['file'] = $ex->getFile(); $frame['line'] = $ex->getLine(); } } // Since the exceptions are likely to share the most shallow frames, // try to add those to the trace only once. if (isset($frame['file']) && isset($frame['line'])) { $signature = $frame['file'].':'.$frame['line']; if (empty($frames[$signature])) { $frames[$signature] = $frame; } } else { $frames[] = $frame; } } // If this is a proxy exception, add the proxied exception. $prev = self::getPreviousException($ex); if ($prev) { $stack[] = array(++$id, $prev); } // If this is an aggregate exception, add the child exceptions. if ($ex instanceof PhutilAggregateException) { foreach ($ex->getExceptions() as $child) { $stack[] = array(++$id, $child); } } } return array_values(array_reverse($frames)); } } diff --git a/src/filesystem/linesofalarge/LinesOfALarge.php b/src/filesystem/linesofalarge/LinesOfALarge.php index 94496cdd..74a33b69 100644 --- a/src/filesystem/linesofalarge/LinesOfALarge.php +++ b/src/filesystem/linesofalarge/LinesOfALarge.php @@ -1,224 +1,224 @@ delimiter = $character; return $this; } /* -( Internals )---------------------------------------------------------- */ /** * Hook, called before @{method:rewind()}. Allows a concrete implementation * to open resources or reset state. * * @return void * @task internals */ abstract protected function willRewind(); /** * Called when the iterator needs more data. The subclass should return more * data, or empty string to indicate end-of-stream. * * @return string Data, or empty string for end-of-stream. * @task internals */ abstract protected function readMore(); /* -( Iterator Interface )------------------------------------------------- */ /** * @task iterator */ final public function rewind() { $this->willRewind(); $this->buf = ''; $this->pos = 0; $this->num = 0; $this->eof = false; $this->valid = true; $this->next(); } /** * @task iterator */ final public function key() { return $this->num; } /** * @task iterator */ final public function current() { return $this->line; } /** * @task iterator */ final public function valid() { return $this->valid; } /** * @task iterator */ final public function next() { // Consume the stream a chunk at a time into an internal buffer, then // read lines out of that buffer. This gives us flexibility (stream sources // only need to be able to read blocks of bytes) and performance (we can // read in reasonably-sized chunks of many lines), at the cost of some // complexity in buffer management. // We do this in a loop to avoid recursion when consuming more bytes, in // case the size of a line is very large compared to the chunk size we // read. while (true) { if (strlen($this->buf)) { // If we don't have a delimiter, return the entire buffer. if ($this->delimiter === null) { $this->num++; $this->line = substr($this->buf, $this->pos); $this->buf = ''; $this->pos = 0; return; } // If we already have some data buffered, try to get the next line from // the buffer. Search through the buffer for a delimiter. This should be // the common case. $endl = strpos($this->buf, $this->delimiter, $this->pos); if ($endl !== false) { // We found a delimiter, so return the line it delimits. We leave // the buffer as-is so we don't need to reallocate it, in case it is // large relative to the size of a line. Instead, we move our cursor // within the buffer forward. $this->num++; $this->line = substr($this->buf, $this->pos, ($endl - $this->pos)); $this->pos = $endl + 1; return; } // We only have part of a line left in the buffer (no delimiter in the // remaining piece), so throw away the part we've already emitted and // continue below. $this->buf = substr($this->buf, $this->pos); $this->pos = 0; } // We weren't able to produce the next line from the bytes we already had // buffered, so read more bytes from the input stream. if ($this->eof) { // NOTE: We keep track of EOF (an empty read) so we don't make any more // reads afterward. Normally, we'll return from the first EOF read, // emit the line, and then next() will be called again. Without tracking // EOF, we'll attempt another read. A well-behaved implementation should // still return empty string, but we can protect against any issues // here by keeping a flag. $more = ''; } else { $more = $this->readMore(); } if (strlen($more)) { // We got some bytes, so add them to the buffer and then try again. $this->buf .= $more; continue; } else { // No more bytes. If we have a buffer, return its contents. We // potentially return part of a line here if the last line had no // delimiter, but that currently seems reasonable as a default // behavior. If we don't have a buffer, we're done. $this->eof = true; if (strlen($this->buf)) { $this->num++; $this->line = $this->buf; - $this->buf = null; + $this->buf = ''; } else { $this->valid = false; } break; } } } } diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php index ecce090c..7aadd61a 100644 --- a/src/future/exec/ExecFuture.php +++ b/src/future/exec/ExecFuture.php @@ -1,1017 +1,1036 @@ array('pipe', 'r'), // stdin 1 => array('pipe', 'w'), // stdout 2 => array('pipe', 'w'), // stderr ); protected function didConstruct() { $this->stdin = new PhutilRope(); } /* -( Command Information )------------------------------------------------ */ /** * Retrieve the byte limit for the stderr buffer. * * @return int Maximum buffer size, in bytes. * @task info */ public function getStderrSizeLimit() { return $this->stderrSizeLimit; } /** * Retrieve the byte limit for the stdout buffer. * * @return int Maximum buffer size, in bytes. * @task info */ public function getStdoutSizeLimit() { return $this->stdoutSizeLimit; } /** * Get the process's pid. This only works after execution is initiated, e.g. * by a call to start(). * * @return int Process ID of the executing process. * @task info */ public function getPID() { $status = $this->procGetStatus(); return $status['pid']; } public function hasPID() { if ($this->procStatus) { return true; } if ($this->proc) { return true; } return false; } /* -( Configuring Execution )---------------------------------------------- */ /** * Set a maximum size for the stdout read buffer. To limit stderr, see * @{method:setStderrSizeLimit}. The major use of these methods is to use less * memory if you are running a command which sometimes produces huge volumes * of output that you don't really care about. * * NOTE: Setting this to 0 means "no buffer", not "unlimited buffer". * * @param int Maximum size of the stdout read buffer. * @return this * @task config */ public function setStdoutSizeLimit($limit) { $this->stdoutSizeLimit = $limit; return $this; } /** * Set a maximum size for the stderr read buffer. * See @{method:setStdoutSizeLimit} for discussion. * * @param int Maximum size of the stderr read buffer. * @return this * @task config */ public function setStderrSizeLimit($limit) { $this->stderrSizeLimit = $limit; return $this; } /** * Set the maximum internal read buffer size this future. The future will * block reads once the internal stdout or stderr buffer exceeds this size. * * NOTE: If you @{method:resolve} a future with a read buffer limit, you may * block forever! * * TODO: We should probably release the read buffer limit during * @{method:resolve}, or otherwise detect this. For now, be careful. * * @param int|null Maximum buffer size, or `null` for unlimited. * @return this */ public function setReadBufferSize($read_buffer_size) { $this->readBufferSize = $read_buffer_size; return $this; } /* -( Interacting With Commands )------------------------------------------ */ /** * Read and return output from stdout and stderr, if any is available. This * method keeps a read cursor on each stream, but the entire streams are * still returned when the future resolves. You can call read() again after * resolving the future to retrieve only the parts of the streams you did not * previously read: * * $future = new ExecFuture('...'); * // ... * list($stdout) = $future->read(); // Returns output so far * list($stdout) = $future->read(); // Returns new output since first call * // ... * list($stdout) = $future->resolvex(); // Returns ALL output * list($stdout) = $future->read(); // Returns unread output * * NOTE: If you set a limit with @{method:setStdoutSizeLimit} or * @{method:setStderrSizeLimit}, this method will not be able to read data * past the limit. * * NOTE: If you call @{method:discardBuffers}, all the stdout/stderr data * will be thrown away and the cursors will be reset. * * @return pair <$stdout, $stderr> pair with new output since the last call * to this method. * @task interact */ public function read() { $stdout = $this->readStdout(); $result = array( $stdout, (string)substr($this->stderr, $this->stderrPos), ); - $this->stderrPos = strlen($this->stderr); + $this->stderrPos = $this->getStderrBufferLength(); return $result; } public function readStdout() { if ($this->start) { $this->updateFuture(); // Sync } $result = (string)substr($this->stdout, $this->stdoutPos); - $this->stdoutPos = strlen($this->stdout); + $this->stdoutPos = $this->getStdoutBufferLength(); + return $result; } /** * Write data to stdin of the command. * * @param string Data to write. * @param bool If true, keep the pipe open for writing. By default, the pipe * will be closed as soon as possible so that commands which * listen for EOF will execute. If you want to keep the pipe open * past the start of command execution, do an empty write with * `$keep_pipe = true` first. * @return this * @task interact */ public function write($data, $keep_pipe = false) { if (strlen($data)) { if (!$this->stdin) { throw new Exception(pht('Writing to a closed pipe!')); } $this->stdin->append($data); } $this->closePipe = !$keep_pipe; return $this; } /** * Permanently discard the stdout and stderr buffers and reset the read * cursors. This is basically useful only if you are streaming a large amount * of data from some process. * * Conceivably you might also need to do this if you're writing a client using * @{class:ExecFuture} and `netcat`, but you probably should not do that. * * NOTE: This completely discards the data. It won't be available when the * future resolves. This is almost certainly only useful if you need the * buffer memory for some reason. * * @return this * @task interact */ public function discardBuffers() { $this->discardStdoutBuffer(); $this->stderr = ''; $this->stderrPos = 0; return $this; } public function discardStdoutBuffer() { $this->stdout = ''; $this->stdoutPos = 0; return $this; } /** * Returns true if this future was killed by a timeout configured with * @{method:setTimeout}. * * @return bool True if the future was killed for exceeding its time limit. */ public function getWasKilledByTimeout() { return $this->killedByTimeout; } /* -( Configuring Execution )---------------------------------------------- */ /** * Set a hard limit on execution time. If the command runs longer, it will * be terminated and the future will resolve with an error code. You can test * if a future was killed by a timeout with @{method:getWasKilledByTimeout}. * * The subprocess will be sent a `TERM` signal, and then a `KILL` signal a * short while later if it fails to exit. * * @param int Maximum number of seconds this command may execute for before * it is signaled. * @return this * @task config */ public function setTimeout($seconds) { $this->terminateTimeout = $seconds; $this->killTimeout = $seconds + min($seconds, 60); return $this; } /* -( Resolving Execution )------------------------------------------------ */ /** * Resolve a command you expect to exit with return code 0. Works like * @{method:resolve}, but throws if $err is nonempty. Returns only * $stdout and $stderr. See also @{function:execx}. * * list($stdout, $stderr) = $future->resolvex(); * * @param float Optional timeout after which resolution will pause and * execution will return to the caller. * @return pair <$stdout, $stderr> pair. * @task resolve */ public function resolvex() { $result = $this->resolve(); return $this->raiseResultError($result); } /** * Resolve a command you expect to return valid JSON. Works like * @{method:resolvex}, but also throws if stderr is nonempty, or stdout is not * valid JSON. Returns a PHP array, decoded from the JSON command output. * * @param float Optional timeout after which resolution will pause and * execution will return to the caller. * @return array PHP array, decoded from JSON command output. * @task resolve */ public function resolveJSON() { list($stdout, $stderr) = $this->resolvex(); if (strlen($stderr)) { $cmd = $this->getCommand(); throw new CommandException( pht( "JSON command '%s' emitted text to stderr when none was expected: %d", $cmd, $stderr), $cmd, 0, $stdout, $stderr); } try { return phutil_json_decode($stdout); } catch (PhutilJSONParserException $ex) { $cmd = $this->getCommand(); throw new CommandException( pht( "JSON command '%s' did not produce a valid JSON object on stdout: %s", $cmd, $stdout), $cmd, 0, $stdout, $stderr); } } /** * Resolve the process by abruptly terminating it. * * @return list List of results. * @task resolve */ public function resolveKill() { if (!$this->hasResult()) { $signal = 9; if ($this->proc) { proc_terminate($this->proc, $signal); } $this->closeProcess(); $result = array( 128 + $signal, $this->stdout, $this->stderr, ); $this->recordResult($result); } return $this->getResult(); } private function recordResult(array $result) { $resolve_on_error = $this->getResolveOnError(); if (!$resolve_on_error) { $result = $this->raiseResultError($result); } $this->setResult($result); } private function raiseResultError($result) { list($err, $stdout, $stderr) = $result; if ($err) { $cmd = $this->getCommand(); if ($this->getWasKilledByTimeout()) { // NOTE: The timeout can be a float and PhutilNumber only handles // integers, so just use "%s" to render it. $message = pht( 'Command killed by timeout after running for more than %s seconds.', $this->terminateTimeout); } else { $message = pht('Command failed with error #%d!', $err); } throw new CommandException( $message, $cmd, $err, $stdout, $stderr); } return array($stdout, $stderr); } /* -( Internals )---------------------------------------------------------- */ /** * Provides read sockets to the future core. * * @return list List of read sockets. * @task internal */ public function getReadSockets() { list($stdin, $stdout, $stderr) = $this->pipes; $sockets = array(); if (isset($stdout) && !feof($stdout)) { $sockets[] = $stdout; } if (isset($stderr) && !feof($stderr)) { $sockets[] = $stderr; } return $sockets; } /** * Provides write sockets to the future core. * * @return list List of write sockets. * @task internal */ public function getWriteSockets() { list($stdin, $stdout, $stderr) = $this->pipes; $sockets = array(); if (isset($stdin) && $this->stdin->getByteLength() && !feof($stdin)) { $sockets[] = $stdin; } return $sockets; } /** * Determine if the read buffer is empty. * * @return bool True if the read buffer is empty. * @task internal */ public function isReadBufferEmpty() { - return !strlen($this->stdout); + return !$this->getStdoutBufferLength(); } /** * Determine if the write buffer is empty. * * @return bool True if the write buffer is empty. * @task internal */ public function isWriteBufferEmpty() { return !$this->getWriteBufferSize(); } /** * Determine the number of bytes in the write buffer. * * @return int Number of bytes in the write buffer. * @task internal */ public function getWriteBufferSize() { if (!$this->stdin) { return 0; } return $this->stdin->getByteLength(); } /** * Reads some bytes from a stream, discarding output once a certain amount * has been accumulated. * * @param resource Stream to read from. * @param int Maximum number of bytes to return from $stream. If * additional bytes are available, they will be read and * discarded. * @param string Human-readable description of stream, for exception * message. * @param int Maximum number of bytes to read. * @return string The data read from the stream. * @task internal */ private function readAndDiscard($stream, $limit, $description, $length) { $output = ''; if ($length <= 0) { return ''; } do { $data = fread($stream, min($length, 64 * 1024)); if (false === $data) { throw new Exception(pht('Failed to read from %s', $description)); } $read_bytes = strlen($data); if ($read_bytes > 0 && $limit > 0) { if ($read_bytes > $limit) { $data = substr($data, 0, $limit); } $output .= $data; $limit -= strlen($data); } if (strlen($output) >= $length) { break; } } while ($read_bytes > 0); return $output; } /** * Begin or continue command execution. * * @return bool True if future has resolved. * @task internal */ public function isReady() { // NOTE: We have a soft dependencies on PhutilErrorTrap here, to avoid // the need to build it into the Phage agent. Under normal circumstances, // this class are always available. if (!$this->pipes) { $is_windows = phutil_is_windows(); if (!$this->start) { // We might already have started the timer via initiating resolution. $this->start = microtime(true); } $unmasked_command = $this->getCommand(); $unmasked_command = $unmasked_command->getUnmaskedString(); $pipes = array(); if ($this->hasEnv()) { $env = $this->getEnv(); } else { $env = null; } $cwd = $this->getCWD(); // NOTE: See note above about Phage. if (class_exists('PhutilErrorTrap')) { $trap = new PhutilErrorTrap(); } else { $trap = null; } $spec = self::$descriptorSpec; if ($is_windows) { $stdout_file = new TempFile(); $stderr_file = new TempFile(); $stdout_handle = fopen($stdout_file, 'wb'); if (!$stdout_handle) { throw new Exception( pht( 'Unable to open stdout temporary file ("%s") for writing.', $stdout_file)); } $stderr_handle = fopen($stderr_file, 'wb'); if (!$stderr_handle) { throw new Exception( pht( 'Unable to open stderr temporary file ("%s") for writing.', $stderr_file)); } $spec = array( 0 => self::$descriptorSpec[0], 1 => $stdout_handle, 2 => $stderr_handle, ); } $proc = @proc_open( $unmasked_command, $spec, $pipes, $cwd, $env, array( 'bypass_shell' => true, )); if ($trap) { $err = $trap->getErrorsAsString(); $trap->destroy(); } else { $err = error_get_last(); if ($err) { $err = $err['message']; } } if ($is_windows) { fclose($stdout_handle); fclose($stderr_handle); } if (!is_resource($proc)) { // When you run an invalid command on a Linux system, the "proc_open()" // works and then the process (really a "/bin/sh -c ...") exits after // it fails to resolve the command. // When you run an invalid command on a Windows system, we bypass the // shell and the "proc_open()" itself fails. See also T13504. Fail the // future immediately, acting as though it exited with an error code // for consistency with Linux. $result = array( 1, '', pht( 'Call to "proc_open()" to open a subprocess failed: %s', $err), ); $this->recordResult($result); return true; } if ($is_windows) { $stdout_handle = fopen($stdout_file, 'rb'); if (!$stdout_handle) { throw new Exception( pht( 'Unable to open stdout temporary file ("%s") for reading.', $stdout_file)); } $stderr_handle = fopen($stderr_file, 'rb'); if (!$stderr_handle) { throw new Exception( pht( 'Unable to open stderr temporary file ("%s") for reading.', $stderr_file)); } $pipes = array( 0 => $pipes[0], 1 => $stdout_handle, 2 => $stderr_handle, ); $this->windowsStdoutTempFile = $stdout_file; $this->windowsStderrTempFile = $stderr_file; } $this->pipes = $pipes; $this->proc = $proc; list($stdin, $stdout, $stderr) = $pipes; if (!$is_windows) { // On Windows, we redirect process standard output and standard error // through temporary files. Files don't block, so we don't need to make // these streams nonblocking. if ((!stream_set_blocking($stdout, false)) || (!stream_set_blocking($stderr, false)) || (!stream_set_blocking($stdin, false))) { $this->__destruct(); throw new Exception(pht('Failed to set streams nonblocking.')); } } $this->tryToCloseStdin(); return false; } if (!$this->proc) { return true; } list($stdin, $stdout, $stderr) = $this->pipes; while (isset($this->stdin) && $this->stdin->getByteLength()) { $write_segment = $this->stdin->getAnyPrefix(); try { $bytes = fwrite($stdin, $write_segment); } catch (RuntimeException $ex) { // If the subprocess has exited, we may get a broken pipe error here // in recent versions of PHP. There does not seem to be any way to // get the actual error code other than reading the exception string. // For now, treat this as if writes are blocked. break; } if ($bytes === false) { throw new Exception(pht('Unable to write to stdin!')); } else if ($bytes) { $this->stdin->removeBytesFromHead($bytes); } else { // Writes are blocked for now. break; } } $this->tryToCloseStdin(); // Read status before reading pipes so that we can never miss data that // arrives between our last read and the process exiting. $status = $this->procGetStatus(); $read_buffer_size = $this->readBufferSize; $max_stdout_read_bytes = PHP_INT_MAX; $max_stderr_read_bytes = PHP_INT_MAX; if ($read_buffer_size !== null) { - $max_stdout_read_bytes = $read_buffer_size - strlen($this->stdout); - $max_stderr_read_bytes = $read_buffer_size - strlen($this->stderr); + $stdout_len = $this->getStdoutBufferLength(); + $stderr_len = $this->getStderrBufferLength(); + + $max_stdout_read_bytes = $read_buffer_size - $stdout_len; + $max_stderr_read_bytes = $read_buffer_size - $stderr_len; } if ($max_stdout_read_bytes > 0) { $this->stdout .= $this->readAndDiscard( $stdout, - $this->getStdoutSizeLimit() - strlen($this->stdout), + $this->getStdoutSizeLimit() - $this->getStdoutBufferLength(), 'stdout', $max_stdout_read_bytes); } if ($max_stderr_read_bytes > 0) { $this->stderr .= $this->readAndDiscard( $stderr, - $this->getStderrSizeLimit() - strlen($this->stderr), + $this->getStderrSizeLimit() - $this->getStderrBufferLength(), 'stderr', $max_stderr_read_bytes); } $is_done = false; if (!$status['running']) { // We may still have unread bytes on stdout or stderr, particularly if // this future is being buffered and streamed. If we do, we don't want to // consider the subprocess to have exited until we've read everything. // See T9724 for context. if (feof($stdout) && feof($stderr)) { $is_done = true; } } if ($is_done) { $signal_info = null; // If the subprocess got nuked with `kill -9`, we get a -1 exitcode. // Upgrade this to a slightly more informative value by examining the // terminating signal code. $err = $status['exitcode']; if ($err == -1) { if ($status['signaled']) { $signo = $status['termsig']; $err = 128 + $signo; $signal_info = pht( "\n\n", phutil_get_signal_name($signo), $signo); } } $result = array( $err, $this->stdout, $signal_info.$this->stderr, ); $this->recordResult($result); $this->closeProcess(); return true; } $elapsed = (microtime(true) - $this->start); if ($this->terminateTimeout && ($elapsed >= $this->terminateTimeout)) { if (!$this->didTerminate) { $this->killedByTimeout = true; $this->sendTerminateSignal(); return false; } } if ($this->killTimeout && ($elapsed >= $this->killTimeout)) { $this->killedByTimeout = true; $this->resolveKill(); return true; } } /** * @return void * @task internal */ public function __destruct() { if (!$this->proc) { return; } // NOTE: If we try to proc_close() an open process, we hang indefinitely. To // avoid this, kill the process explicitly if it's still running. $status = $this->procGetStatus(); if ($status['running']) { $this->sendTerminateSignal(); if (!$this->waitForExit(5)) { $this->resolveKill(); } } else { $this->closeProcess(); } } /** * Close and free resources if necessary. * * @return void * @task internal */ private function closeProcess() { foreach ($this->pipes as $pipe) { if (isset($pipe)) { @fclose($pipe); } } $this->pipes = array(null, null, null); if ($this->proc) { @proc_close($this->proc); $this->proc = null; } $this->stdin = null; unset($this->windowsStdoutTempFile); unset($this->windowsStderrTempFile); } /** * Execute `proc_get_status()`, but avoid pitfalls. * * @return dict Process status. * @task internal */ private function procGetStatus() { // After the process exits, we only get one chance to read proc_get_status() // before it starts returning garbage. Make sure we don't throw away the // last good read. if ($this->procStatus) { if (!$this->procStatus['running']) { return $this->procStatus; } } // See T13555. This may occur if you call "getPID()" on a future which // exited immediately without ever creating a valid subprocess. if (!$this->proc) { throw new Exception( pht( 'Attempting to get subprocess status in "ExecFuture" with no '. 'valid subprocess.')); } $this->procStatus = proc_get_status($this->proc); return $this->procStatus; } /** * Try to close stdin, if we're done using it. This keeps us from hanging if * the process on the other end of the pipe is waiting for EOF. * * @return void * @task internal */ private function tryToCloseStdin() { if (!$this->closePipe) { // We've been told to keep the pipe open by a call to write(..., true). return; } if ($this->stdin->getByteLength()) { // We still have bytes to write. return; } list($stdin) = $this->pipes; if (!$stdin) { // We've already closed stdin. return; } // There's nothing stopping us from closing stdin, so close it. @fclose($stdin); $this->pipes[0] = null; } public function getDefaultWait() { $wait = parent::getDefaultWait(); $next_timeout = $this->getNextTimeout(); if ($next_timeout) { if (!$this->start) { $this->start = microtime(true); } $elapsed = (microtime(true) - $this->start); $wait = max(0, min($next_timeout - $elapsed, $wait)); } return $wait; } private function getNextTimeout() { if ($this->didTerminate) { return $this->killTimeout; } else { return $this->terminateTimeout; } } private function sendTerminateSignal() { $this->didTerminate = true; proc_terminate($this->proc); return $this; } private function waitForExit($duration) { $start = microtime(true); while (true) { $status = $this->procGetStatus(); if (!$status['running']) { return true; } $waited = (microtime(true) - $start); if ($waited > $duration) { return false; } } } protected function getServiceProfilerStartParameters() { return array( 'type' => 'exec', 'command' => phutil_string_cast($this->getCommand()), ); } protected function getServiceProfilerResultParameters() { if ($this->hasResult()) { $result = $this->getResult(); $err = idx($result, 0); } else { $err = null; } return array( 'err' => $err, ); } + private function getStdoutBufferLength() { + if ($this->stdout === null) { + return 0; + } + + return strlen($this->stdout); + } + + private function getStderrBufferLength() { + if ($this->stderr === null) { + return 0; + } + + return strlen($this->stderr); + } } diff --git a/src/future/http/BaseHTTPFuture.php b/src/future/http/BaseHTTPFuture.php index 14562ebe..1def346b 100644 --- a/src/future/http/BaseHTTPFuture.php +++ b/src/future/http/BaseHTTPFuture.php @@ -1,458 +1,460 @@ resolve(); * * This is an abstract base class which defines the API that HTTP futures * conform to. Concrete implementations are available in @{class:HTTPFuture} * and @{class:HTTPSFuture}. All futures return a tuple * when resolved; status is an object of class @{class:HTTPFutureResponseStatus} * and may represent any of a wide variety of errors in the transport layer, * a support library, or the actual HTTP exchange. * * @task create Creating a New Request * @task config Configuring the Request * @task resolve Resolving the Request * @task internal Internals */ abstract class BaseHTTPFuture extends Future { private $method = 'GET'; private $timeout = 300.0; private $headers = array(); private $uri; private $data; private $expect; private $disableContentDecoding; /* -( Creating a New Request )--------------------------------------------- */ /** * Build a new future which will make an HTTP request to a given URI, with * some optional data payload. Since this class is abstract you can't actually * instantiate it; instead, build a new @{class:HTTPFuture} or * @{class:HTTPSFuture}. * * @param string Fully-qualified URI to send a request to. * @param mixed String or array to include in the request. Strings will be * transmitted raw; arrays will be encoded and sent as * 'application/x-www-form-urlencoded'. * @task create */ final public function __construct($uri, $data = array()) { $this->setURI((string)$uri); $this->setData($data); } /* -( Configuring the Request )-------------------------------------------- */ /** * Set a timeout for the service call. If the request hasn't resolved yet, * the future will resolve with a status that indicates the request timed * out. You can determine if a status is a timeout status by calling * isTimeout() on the status object. * * @param float Maximum timeout, in seconds. * @return this * @task config */ public function setTimeout($timeout) { $this->timeout = $timeout; return $this; } /** * Get the currently configured timeout. * * @return float Maximum number of seconds the request will execute for. * @task config */ public function getTimeout() { return $this->timeout; } /** * Select the HTTP method (e.g., "GET", "POST", "PUT") to use for the request. * By default, requests use "GET". * * @param string HTTP method name. * @return this * @task config */ final public function setMethod($method) { static $supported_methods = array( 'GET' => true, 'POST' => true, 'PUT' => true, 'DELETE' => true, ); if (empty($supported_methods[$method])) { throw new Exception( pht( "The HTTP method '%s' is not supported. Supported HTTP methods ". "are: %s.", $method, implode(', ', array_keys($supported_methods)))); } $this->method = $method; return $this; } /** * Get the HTTP method the request will use. * * @return string HTTP method name, like "GET". * @task config */ final public function getMethod() { return $this->method; } /** * Set the URI to send the request to. Note that this is also a constructor * parameter. * * @param string URI to send the request to. * @return this * @task config */ public function setURI($uri) { $this->uri = (string)$uri; return $this; } /** * Get the fully-qualified URI the request will be made to. * * @return string URI the request will be sent to. * @task config */ public function getURI() { return $this->uri; } /** * Provide data to send along with the request. Note that this is also a * constructor parameter; it may be more convenient to provide it there. Data * must be a string (in which case it will be sent raw) or an array (in which * case it will be encoded and sent as 'application/x-www-form-urlencoded'). * * @param mixed Data to send with the request. * @return this * @task config */ public function setData($data) { if (!is_string($data) && !is_array($data)) { throw new Exception(pht('Data parameter must be an array or string.')); } $this->data = $data; return $this; } /** * Get the data which will be sent with the request. * * @return mixed Data which will be sent. * @task config */ public function getData() { return $this->data; } /** * Add an HTTP header to the request. The same header name can be specified * more than once, which will cause multiple headers to be sent. * * @param string Header name, like "Accept-Language". * @param string Header value, like "en-us". * @return this * @task config */ public function addHeader($name, $value) { $this->headers[] = array($name, $value); return $this; } /** * Get headers which will be sent with the request. Optionally, you can * provide a filter, which will return only headers with that name. For * example: * * $all_headers = $future->getHeaders(); * $just_user_agent = $future->getHeaders('User-Agent'); * * In either case, an array with all (or all matching) headers is returned. * * @param string|null Optional filter, which selects only headers with that * name if provided. * @return array List of all (or all matching) headers. * @task config */ public function getHeaders($filter = null) { - $filter = strtolower($filter); + if ($filter !== null) { + $filter = phutil_utf8_strtolower($filter); + } $result = array(); foreach ($this->headers as $header) { list($name, $value) = $header; - if (!$filter || ($filter == strtolower($name))) { + if (($filter === null) || ($filter === phutil_utf8_strtolower($name))) { $result[] = $header; } } return $result; } /** * Set the status codes that are expected in the response. * If set, isError on the status object will return true for status codes * that are not in the input array. Otherwise, isError will be true for any * HTTP status code outside the 2xx range (notwithstanding other errors such * as connection or transport issues). * * @param array|null List of expected HTTP status codes. * * @return this * @task config */ public function setExpectStatus($status_codes) { $this->expect = $status_codes; return $this; } /** * Return list of expected status codes, or null if not set. * * @return array|null List of expected status codes. */ public function getExpectStatus() { return $this->expect; } /** * Add a HTTP basic authentication header to the request. * * @param string Username to authenticate with. * @param PhutilOpaqueEnvelope Password to authenticate with. * @return this * @task config */ public function setHTTPBasicAuthCredentials( $username, PhutilOpaqueEnvelope $password) { $password_plaintext = $password->openEnvelope(); $credentials = base64_encode($username.':'.$password_plaintext); return $this->addHeader('Authorization', 'Basic '.$credentials); } public function getHTTPRequestByteLength() { // NOTE: This isn't very accurate, but it's only used by the "--trace" // call profiler to help pick out huge requests. $data = $this->getData(); if (is_scalar($data)) { return strlen($data); } return strlen(phutil_build_http_querystring($data)); } public function setDisableContentDecoding($disable_decoding) { $this->disableContentDecoding = $disable_decoding; return $this; } public function getDisableContentDecoding() { return $this->disableContentDecoding; } /* -( Resolving the Request )---------------------------------------------- */ /** * Exception-oriented @{method:resolve}. Throws if the status indicates an * error occurred. * * @return tuple HTTP request result tuple. * @task resolve */ final public function resolvex() { $result = $this->resolve(); list($status, $body, $headers) = $result; if ($status->isError()) { throw $status; } return array($body, $headers); } /* -( Internals )---------------------------------------------------------- */ /** * Parse a raw HTTP response into a tuple. * * @param string Raw HTTP response. * @return tuple Valid resolution tuple. * @task internal */ protected function parseRawHTTPResponse($raw_response) { $rex_base = "@^(?P.*?)\r?\n\r?\n(?P.*)$@s"; $rex_head = "@^HTTP/\S+ (?P\d+) ?(?P.*?)". "(?:\r?\n(?P.*))?$@s"; // We need to parse one or more header blocks in case we got any // "HTTP/1.X 100 Continue" nonsense back as part of the response. This // happens with HTTPS requests, at the least. $response = $raw_response; while (true) { $matches = null; if (!preg_match($rex_base, $response, $matches)) { return $this->buildMalformedResult($raw_response); } $head = $matches['head']; $body = $matches['body']; if (!preg_match($rex_head, $head, $matches)) { return $this->buildMalformedResult($raw_response); } $response_code = (int)$matches['code']; $response_status = strtolower($matches['status']); if ($response_code == 100) { // This is HTTP/1.X 100 Continue, so this whole chunk is moot. $response = $body; } else if (($response_code == 200) && ($response_status == 'connection established')) { // When tunneling through an HTTPS proxy, we get an initial header // block like "HTTP/1.X 200 Connection established", then newlines, // then the normal response. Drop this chunk. $response = $body; } else { $headers = $this->parseHeaders(idx($matches, 'headers')); break; } } if (!$this->getDisableContentDecoding()) { $content_encoding = null; foreach ($headers as $header) { list($name, $value) = $header; $name = phutil_utf8_strtolower($name); if (!strcasecmp($name, 'Content-Encoding')) { $content_encoding = $value; break; } } switch ($content_encoding) { case 'gzip': $decoded_body = @gzdecode($body); if ($decoded_body === false) { return $this->buildMalformedResult($raw_response); } $body = $decoded_body; break; } } $status = new HTTPFutureHTTPResponseStatus( $response_code, $body, $headers, $this->expect); return array($status, $body, $headers); } /** * Parse an HTTP header block. * * @param string Raw HTTP headers. * @return list List of HTTP header tuples. * @task internal */ protected function parseHeaders($head_raw) { $rex_header = '@^(?P.*?):\s*(?P.*)$@'; $headers = array(); if (!$head_raw) { return $headers; } $headers_raw = preg_split("/\r?\n/", $head_raw); foreach ($headers_raw as $header) { $m = null; if (preg_match($rex_header, $header, $m)) { $headers[] = array($m['name'], $m['value']); } else { $headers[] = array($header, null); } } return $headers; } /** * Find value of the first header with given name. * * @param list List of headers from `resolve()`. * @param string Case insensitive header name. * @return string Value of the header or null if not found. * @task resolve */ public static function getHeader(array $headers, $search) { assert_instances_of($headers, 'array'); foreach ($headers as $header) { list($name, $value) = $header; if (strcasecmp($name, $search) == 0) { return $value; } } return null; } /** * Build a result tuple indicating a parse error resulting from a malformed * HTTP response. * * @return tuple Valid resolution tuple. * @task internal */ protected function buildMalformedResult($raw_response) { $body = null; $headers = array(); $status = new HTTPFutureParseResponseStatus( HTTPFutureParseResponseStatus::ERROR_MALFORMED_RESPONSE, $raw_response); return array($status, $body, $headers); } } diff --git a/src/future/http/HTTPSFuture.php b/src/future/http/HTTPSFuture.php index c217c112..48824fb1 100644 --- a/src/future/http/HTTPSFuture.php +++ b/src/future/http/HTTPSFuture.php @@ -1,878 +1,878 @@ cabundle = $temp; return $this; } /** * Set the SSL certificate to use for this session, given a path. * * @param string The path to a valid SSL certificate for this session * @return this */ public function setCABundleFromPath($path) { $this->cabundle = $path; return $this; } /** * Get the path to the SSL certificate for this session. * * @return string|null */ public function getCABundle() { return $this->cabundle; } /** * Set whether Location headers in the response will be respected. * The default is true. * * @param boolean true to follow any Location header present in the response, * false to return the request directly * @return this */ public function setFollowLocation($follow) { $this->followLocation = $follow; return $this; } /** * Get whether Location headers in the response will be respected. * * @return boolean */ public function getFollowLocation() { return $this->followLocation; } /** * Set the fallback CA certificate if one is not specified * for the session, given a path. * * @param string The path to a valid SSL certificate * @return void */ public static function setGlobalCABundleFromPath($path) { self::$globalCABundle = $path; } /** * Set the fallback CA certificate if one is not specified * for the session, given a string. * * @param string The certificate * @return void */ public static function setGlobalCABundleFromString($certificate) { $temp = new TempFile(); Filesystem::writeFile($temp, $certificate); self::$globalCABundle = $temp; } /** * Get the fallback global CA certificate * * @return string */ public static function getGlobalCABundle() { return self::$globalCABundle; } /** * Load contents of remote URI. Behaves pretty much like * `@file_get_contents($uri)` but doesn't require `allow_url_fopen`. * * @param string * @param float * @return string|false */ public static function loadContent($uri, $timeout = null) { $future = new self($uri); if ($timeout !== null) { $future->setTimeout($timeout); } try { list($body) = $future->resolvex(); return $body; } catch (HTTPFutureResponseStatus $ex) { return false; } } public function setDownloadPath($download_path) { $this->downloadPath = $download_path; if (Filesystem::pathExists($download_path)) { throw new Exception( pht( 'Specified download path "%s" already exists, refusing to '. 'overwrite.', $download_path)); } return $this; } public function setProgressSink(PhutilProgressSink $progress_sink) { $this->progressSink = $progress_sink; return $this; } public function getProgressSink() { return $this->progressSink; } /** * See T13533. This supports an install-specific Kerberos workflow. */ public function addCURLOption($option_key, $option_value) { if (!is_scalar($option_key)) { throw new Exception( pht( 'Expected option key passed to "addCurlOption(, ...)" to be '. 'a scalar, got "%s".', phutil_describe_type($option_key))); } $this->curlOptions[] = array($option_key, $option_value); return $this; } /** * Attach a file to the request. * * @param string HTTP parameter name. * @param string File content. * @param string File name. * @param string File mime type. * @return this */ public function attachFileData($key, $data, $name, $mime_type) { if (isset($this->files[$key])) { throw new Exception( pht( '%s currently supports only one file attachment for each '. 'parameter name. You are trying to attach two different files with '. 'the same parameter, "%s".', __CLASS__, $key)); } $this->files[$key] = array( 'data' => $data, 'name' => $name, 'mime' => $mime_type, ); return $this; } public function isReady() { if ($this->hasResult()) { return true; } $uri = $this->getURI(); $domain = id(new PhutilURI($uri))->getDomain(); $is_download = $this->isDownload(); // See T13396. For now, use the streaming response parser only if we're // downloading the response to disk. $use_streaming_parser = (bool)$is_download; if (!$this->handle) { $uri_object = new PhutilURI($uri); $proxy = PhutilHTTPEngineExtension::buildHTTPProxyURI($uri_object); // TODO: Currently, the "proxy" is not passed to the ServiceProfiler // because of changes to how ServiceProfiler is integrated. It would // be nice to pass it again. if (!self::$multi) { self::$multi = curl_multi_init(); if (!self::$multi) { throw new Exception(pht('%s failed!', 'curl_multi_init()')); } } if (!empty(self::$pool[$domain])) { $curl = array_pop(self::$pool[$domain]); } else { $curl = curl_init(); if (!$curl) { throw new Exception(pht('%s failed!', 'curl_init()')); } } $this->handle = $curl; curl_multi_add_handle(self::$multi, $curl); curl_setopt($curl, CURLOPT_URL, $uri); if (defined('CURLOPT_PROTOCOLS')) { // cURL supports a lot of protocols, and by default it will honor // redirects across protocols (for instance, from HTTP to POP3). Beyond // being very silly, this also has security implications: // // http://blog.volema.com/curl-rce.html // // Disable all protocols other than HTTP and HTTPS. $allowed_protocols = CURLPROTO_HTTPS | CURLPROTO_HTTP; curl_setopt($curl, CURLOPT_PROTOCOLS, $allowed_protocols); curl_setopt($curl, CURLOPT_REDIR_PROTOCOLS, $allowed_protocols); } - if (strlen($this->rawBody)) { + if ($this->rawBody !== null) { if ($this->getData()) { throw new Exception( pht( 'You can not execute an HTTP future with both a raw request '. 'body and structured request data.')); } // We aren't actually going to use this file handle, since we are // just pushing data through the callback, but cURL gets upset if // we don't hand it a real file handle. $tmp = new TempFile(); $this->fileHandle = fopen($tmp, 'r'); // NOTE: We must set CURLOPT_PUT here to make cURL use CURLOPT_INFILE. // We'll possibly overwrite the method later on, unless this is really // a PUT request. curl_setopt($curl, CURLOPT_PUT, true); curl_setopt($curl, CURLOPT_INFILE, $this->fileHandle); curl_setopt($curl, CURLOPT_INFILESIZE, strlen($this->rawBody)); curl_setopt($curl, CURLOPT_READFUNCTION, array($this, 'willWriteBody')); } else { $data = $this->formatRequestDataForCURL(); curl_setopt($curl, CURLOPT_POSTFIELDS, $data); } $headers = $this->getHeaders(); $saw_expect = false; $saw_accept = false; for ($ii = 0; $ii < count($headers); $ii++) { list($name, $value) = $headers[$ii]; $headers[$ii] = $name.': '.$value; if (!strcasecmp($name, 'Expect')) { $saw_expect = true; } if (!strcasecmp($name, 'Accept-Encoding')) { $saw_accept = true; } } if (!$saw_expect) { // cURL sends an "Expect" header by default for certain requests. While // there is some reasoning behind this, it causes a practical problem // in that lighttpd servers reject these requests with a 417. Both sides // are locked in an eternal struggle (lighttpd has introduced a // 'server.reject-expect-100-with-417' option to deal with this case). // // The ostensibly correct way to suppress this behavior on the cURL side // is to add an empty "Expect:" header. If we haven't seen some other // explicit "Expect:" header, do so. // // See here, for example, although this issue is fairly widespread: // http://curl.haxx.se/mail/archive-2009-07/0008.html $headers[] = 'Expect:'; } if (!$saw_accept) { if (!$use_streaming_parser) { if ($this->canAcceptGzip()) { $headers[] = 'Accept-Encoding: gzip'; } } } curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); // Set the requested HTTP method, e.g. GET / POST / PUT. curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $this->getMethod()); // Make sure we get the headers and data back. curl_setopt($curl, CURLOPT_HEADER, true); curl_setopt($curl, CURLOPT_WRITEFUNCTION, array($this, 'didReceiveDataCallback')); if ($this->followLocation) { curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); curl_setopt($curl, CURLOPT_MAXREDIRS, 20); } if (defined('CURLOPT_TIMEOUT_MS')) { // If CURLOPT_TIMEOUT_MS is available, use the higher-precision timeout. $timeout = max(1, ceil(1000 * $this->getTimeout())); curl_setopt($curl, CURLOPT_TIMEOUT_MS, $timeout); } else { // Otherwise, fall back to the lower-precision timeout. $timeout = max(1, ceil($this->getTimeout())); curl_setopt($curl, CURLOPT_TIMEOUT, $timeout); } // We're going to try to set CAINFO below. This doesn't work at all on // OSX around Yosemite (see T5913). On these systems, we'll use the // system CA and then try to tell the user that their settings were // ignored and how to fix things if we encounter a CA-related error. // Assume we have custom CA settings to start with; we'll clear this // flag if we read the default CA info below. // Try some decent fallbacks here: // - First, check if a bundle is set explicitly for this request, via // `setCABundle()` or similar. // - Then, check if a global bundle is set explicitly for all requests, // via `setGlobalCABundle()` or similar. // - Then, if a local custom.pem exists, use that, because it probably // means that the user wants to override everything (also because the // user might not have access to change the box's php.ini to add // curl.cainfo). // - Otherwise, try using curl.cainfo. If it's set explicitly, it's // probably reasonable to try using it before we fall back to what // libphutil ships with. // - Lastly, try the default that libphutil ships with. If it doesn't // work, give up and yell at the user. if (!$this->getCABundle()) { $caroot = dirname(phutil_get_library_root('arcanist')); $caroot = $caroot.'/resources/ssl/'; $ini_val = ini_get('curl.cainfo'); if (self::getGlobalCABundle()) { $this->setCABundleFromPath(self::getGlobalCABundle()); } else if (Filesystem::pathExists($caroot.'custom.pem')) { $this->setCABundleFromPath($caroot.'custom.pem'); } else if ($ini_val) { // TODO: We can probably do a pathExists() here, even. $this->setCABundleFromPath($ini_val); } else { $this->setCABundleFromPath($caroot.'default.pem'); } } if ($this->canSetCAInfo()) { curl_setopt($curl, CURLOPT_CAINFO, $this->getCABundle()); } $verify_peer = 1; $verify_host = 2; $extensions = PhutilHTTPEngineExtension::getAllExtensions(); foreach ($extensions as $extension) { if ($extension->shouldTrustAnySSLAuthorityForURI($uri_object)) { $verify_peer = 0; } if ($extension->shouldTrustAnySSLHostnameForURI($uri_object)) { $verify_host = 0; } } curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $verify_peer); curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $verify_host); curl_setopt($curl, CURLOPT_SSLVERSION, 0); // See T13391. Recent versions of cURL default to "HTTP/2" on some // connections, but do not support HTTP/2 proxies. Until HTTP/2 // stabilizes, force HTTP/1.1 explicitly. curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); if ($proxy) { curl_setopt($curl, CURLOPT_PROXY, (string)$proxy); } foreach ($this->curlOptions as $curl_option) { list($curl_key, $curl_value) = $curl_option; try { $ok = curl_setopt($curl, $curl_key, $curl_value); if (!$ok) { throw new Exception( pht( 'Call to "curl_setopt(...)" returned "false".')); } } catch (Exception $ex) { throw new PhutilProxyException( pht( 'Call to "curl_setopt(...) failed for option key "%s".', $curl_key), $ex); } } if ($is_download) { $this->downloadHandle = @fopen($this->downloadPath, 'wb+'); if (!$this->downloadHandle) { throw new Exception( pht( 'Failed to open filesystem path "%s" for writing.', $this->downloadPath)); } } if ($use_streaming_parser) { $streaming_parser = id(new PhutilHTTPResponseParser()) ->setFollowLocationHeaders($this->getFollowLocation()); if ($this->downloadHandle) { $streaming_parser->setWriteHandle($this->downloadHandle); } $progress_sink = $this->getProgressSink(); if ($progress_sink) { $streaming_parser->setProgressSink($progress_sink); } $this->parser = $streaming_parser; } } else { $curl = $this->handle; if (!self::$results) { // NOTE: In curl_multi_select(), PHP calls curl_multi_fdset() but does // not check the return value of &maxfd for -1 until recent versions // of PHP (5.4.8 and newer). cURL may return -1 as maxfd in some unusual // situations; if it does, PHP enters select() with nfds=0, which blocks // until the timeout is reached. // // We could try to guess whether this will happen or not by examining // the version identifier, but we can also just sleep for only a short // period of time. curl_multi_select(self::$multi, 0.01); } } do { $active = null; $result = curl_multi_exec(self::$multi, $active); } while ($result == CURLM_CALL_MULTI_PERFORM); while ($info = curl_multi_info_read(self::$multi)) { if ($info['msg'] == CURLMSG_DONE) { self::$results[(int)$info['handle']] = $info; } } if (!array_key_exists((int)$curl, self::$results)) { return false; } // The request is complete, so release any temporary files we wrote // earlier. $this->temporaryFiles = array(); $info = self::$results[(int)$curl]; $result = $this->responseBuffer; $err_code = $info['result']; if ($err_code) { if (($err_code == CURLE_SSL_CACERT) && !$this->canSetCAInfo()) { $status = new HTTPFutureCertificateResponseStatus( HTTPFutureCertificateResponseStatus::ERROR_IMMUTABLE_CERTIFICATES, $uri); } else { $status = new HTTPFutureCURLResponseStatus($err_code, $uri); } $body = null; $headers = array(); $this->setResult(array($status, $body, $headers)); } else if ($this->parser) { $streaming_parser = $this->parser; try { $responses = $streaming_parser->getResponses(); $final_response = last($responses); $result = array( $final_response->getStatus(), $final_response->getBody(), $final_response->getHeaders(), ); } catch (HTTPFutureParseResponseStatus $ex) { $result = array($ex, null, array()); } $this->setResult($result); } else { // cURL returns headers of all redirects, we strip all but the final one. $redirects = curl_getinfo($curl, CURLINFO_REDIRECT_COUNT); $result = preg_replace('/^(.*\r\n\r\n){'.$redirects.'}/sU', '', $result); $this->setResult($this->parseRawHTTPResponse($result)); } curl_multi_remove_handle(self::$multi, $curl); unset(self::$results[(int)$curl]); // NOTE: We want to use keepalive if possible. Return the handle to a // pool for the domain; don't close it. if ($this->shouldReuseHandles()) { self::$pool[$domain][] = $curl; } if ($is_download) { if ($this->downloadHandle) { fflush($this->downloadHandle); fclose($this->downloadHandle); $this->downloadHandle = null; } } $sink = $this->getProgressSink(); if ($sink) { $status = head($this->getResult()); if ($status->isError()) { $sink->didFailWork(); } else { $sink->didCompleteWork(); } } return true; } /** * Callback invoked by cURL as it reads HTTP data from the response. We save * the data to a buffer. */ public function didReceiveDataCallback($handle, $data) { if ($this->parser) { $this->parser->readBytes($data); } else { $this->responseBuffer .= $data; } return strlen($data); } /** * Read data from the response buffer. * * NOTE: Like @{class:ExecFuture}, this method advances a read cursor but * does not discard the data. The data will still be buffered, and it will * all be returned when the future resolves. To discard the data after * reading it, call @{method:discardBuffers}. * * @return string Response data, if available. */ public function read() { if ($this->isDownload()) { throw new Exception( pht( 'You can not read the result buffer while streaming results '. 'to disk: there is no in-memory buffer to read.')); } if ($this->parser) { throw new Exception( pht( 'Streaming reads are not currently supported by the streaming '. 'parser.')); } $result = substr($this->responseBuffer, $this->responseBufferPos); $this->responseBufferPos = strlen($this->responseBuffer); return $result; } /** * Discard any buffered data. Normally, you call this after reading the * data with @{method:read}. * * @return this */ public function discardBuffers() { if ($this->isDownload()) { throw new Exception( pht( 'You can not discard the result buffer while streaming results '. 'to disk: there is no in-memory buffer to discard.')); } if ($this->parser) { throw new Exception( pht( 'Buffer discards are not currently supported by the streaming '. 'parser.')); } $this->responseBuffer = ''; $this->responseBufferPos = 0; return $this; } /** * Produces a value safe to pass to `CURLOPT_POSTFIELDS`. * * @return wild Some value, suitable for use in `CURLOPT_POSTFIELDS`. */ private function formatRequestDataForCURL() { // We're generating a value to hand to cURL as CURLOPT_POSTFIELDS. The way // cURL handles this value has some tricky caveats. // First, we can return either an array or a query string. If we return // an array, we get a "multipart/form-data" request. If we return a // query string, we get an "application/x-www-form-urlencoded" request. // Second, if we return an array we can't duplicate keys. The user might // want to send the same parameter multiple times. // Third, if we return an array and any of the values start with "@", // cURL includes arbitrary files off disk and sends them to an untrusted // remote server. For example, an array like: // // array('name' => '@/usr/local/secret') // // ...will attempt to read that file off disk and transmit its contents with // the request. This behavior is pretty surprising, and it can easily // become a relatively severe security vulnerability which allows an // attacker to read any file the HTTP process has access to. Since this // feature is very dangerous and not particularly useful, we prevent its // use. Broadly, this means we must reject some requests because they // contain an "@" in an inconvenient place. // Generally, to avoid the "@" case and because most servers usually // expect "application/x-www-form-urlencoded" data, we try to return a // string unless there are files attached to this request. $data = $this->getData(); $files = $this->files; $any_data = ($data || (is_string($data) && strlen($data))); $any_files = (bool)$this->files; if (!$any_data && !$any_files) { // No files or data, so just bail. return null; } if (!$any_files) { // If we don't have any files, just encode the data as a query string, // make sure it's not including any files, and we're good to go. if (is_array($data)) { $data = phutil_build_http_querystring($data); } $this->checkForDangerousCURLMagic($data, $is_query_string = true); return $data; } // If we've made it this far, we have some files, so we need to return // an array. First, convert the other data into an array if it isn't one // already. if (is_string($data)) { // NOTE: We explicitly don't want fancy array parsing here, so just // do a basic parse and then convert it into a dictionary ourselves. $parser = new PhutilQueryStringParser(); $pairs = $parser->parseQueryStringToPairList($data); $map = array(); foreach ($pairs as $pair) { list($key, $value) = $pair; if (array_key_exists($key, $map)) { throw new Exception( pht( 'Request specifies two values for key "%s", but parameter '. 'names must be unique if you are posting file data due to '. 'limitations with cURL.', $key)); } $map[$key] = $value; } $data = $map; } foreach ($data as $key => $value) { $this->checkForDangerousCURLMagic($value, $is_query_string = false); } foreach ($this->files as $name => $info) { if (array_key_exists($name, $data)) { throw new Exception( pht( 'Request specifies a file with key "%s", but that key is also '. 'defined by normal request data. Due to limitations with cURL, '. 'requests that post file data must use unique keys.', $name)); } $tmp = new TempFile($info['name']); Filesystem::writeFile($tmp, $info['data']); $this->temporaryFiles[] = $tmp; // In 5.5.0 and later, we can use CURLFile. Prior to that, we have to // use this "@" stuff. if (class_exists('CURLFile', false)) { $file_value = new CURLFile((string)$tmp, $info['mime'], $info['name']); } else { $file_value = '@'.(string)$tmp; } $data[$name] = $file_value; } return $data; } /** * Detect strings which will cause cURL to do horrible, insecure things. * * @param string Possibly dangerous string. * @param bool True if this string is being used as part of a query string. * @return void */ private function checkForDangerousCURLMagic($string, $is_query_string) { if (empty($string[0]) || ($string[0] != '@')) { // This isn't an "@..." string, so it's fine. return; } if ($is_query_string) { if (version_compare(phpversion(), '5.2.0', '<')) { throw new Exception( pht( 'Attempting to make an HTTP request, but query string data begins '. 'with "%s". Prior to PHP 5.2.0 this reads files off disk, which '. 'creates a wide attack window for security vulnerabilities. '. 'Upgrade PHP or avoid making cURL requests which begin with "%s".', '@', '@')); } // This is safe if we're on PHP 5.2.0 or newer. return; } throw new Exception( pht( 'Attempting to make an HTTP request which includes file data, but the '. 'value of a query parameter begins with "%s". PHP interprets these '. 'values to mean that it should read arbitrary files off disk and '. 'transmit them to remote servers. Declining to make this request.', '@')); } /** * Determine whether CURLOPT_CAINFO is usable on this system. */ private function canSetCAInfo() { // We cannot set CAInfo on OSX after Yosemite. $osx_version = PhutilExecutionEnvironment::getOSXVersion(); if ($osx_version) { if (version_compare($osx_version, 14, '>=')) { return false; } } return true; } /** * Write a raw HTTP body into the request. * * You must write the entire body before starting the request. * * @param string Raw body. * @return this */ public function write($raw_body) { $this->rawBody = $raw_body; return $this; } /** * Callback to pass data to cURL. */ public function willWriteBody($handle, $infile, $len) { $bytes = substr($this->rawBody, $this->rawBodyPos, $len); $this->rawBodyPos += $len; return $bytes; } private function shouldReuseHandles() { $curl_version = curl_version(); $version = idx($curl_version, 'version'); // NOTE: cURL 7.43.0 has a bug where the POST body length is not recomputed // properly when a handle is reused. For this version of cURL, disable // handle reuse and accept a small performance penalty. See T8654. if ($version == '7.43.0') { return false; } return true; } private function isDownload() { return ($this->downloadPath !== null); } protected function getServiceProfilerStartParameters() { return array( 'type' => 'http', 'uri' => phutil_string_cast($this->getURI()), ); } private function canAcceptGzip() { return function_exists('gzdecode'); } } diff --git a/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php index 3a493e2b..d95513ce 100644 --- a/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php +++ b/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php @@ -1,31 +1,39 @@ selectTokensOfType('T_COMMENT') as $comment) { $value = $comment->getValue(); if ($value[0] !== '#') { continue; } + // Don't warn about PHP comment directives. In particular, we need + // to use "#[\ReturnTypeWillChange]" to implement "Iterator" in a way + // that is compatible with PHP 8.1 and older versions of PHP prior + // to the introduction of return types. See T13588. + if (preg_match('/^#\\[\\\\/', $value)) { + continue; + } + $this->raiseLintAtOffset( $comment->getOffset(), pht( 'Use `%s` single-line comments, not `%s`.', '//', '#'), '#', preg_match('/^#\S/', $value) ? '// ' : '//'); } } } diff --git a/src/lint/linter/xhpast/rules/__tests__/comment-style/hash-directives.lint-test b/src/lint/linter/xhpast/rules/__tests__/comment-style/hash-directives.lint-test new file mode 100644 index 00000000..8ea0ed16 --- /dev/null +++ b/src/lint/linter/xhpast/rules/__tests__/comment-style/hash-directives.lint-test @@ -0,0 +1,23 @@ +throwOnAttemptedIteration(); } + #[\ReturnTypeWillChange] public function key() { $this->throwOnAttemptedIteration(); } + #[\ReturnTypeWillChange] public function next() { $this->throwOnAttemptedIteration(); } + #[\ReturnTypeWillChange] public function rewind() { $this->throwOnAttemptedIteration(); } + #[\ReturnTypeWillChange] public function valid() { $this->throwOnAttemptedIteration(); } private function throwOnAttemptedIteration() { throw new DomainException( pht( 'Attempting to iterate an object (of class %s) which is not iterable.', get_class($this))); } /** * Read the value of a class constant. * * This is the same as just typing `self::CONSTANTNAME`, but throws a more * useful message if the constant is not defined and allows the constant to * be limited to a maximum length. * * @param string Name of the constant. * @param int|null Maximum number of bytes permitted in the value. * @return string Value of the constant. */ public function getPhobjectClassConstant($key, $byte_limit = null) { $class = new ReflectionClass($this); $const = $class->getConstant($key); if ($const === false) { throw new Exception( pht( '"%s" class "%s" must define a "%s" constant.', __CLASS__, get_class($this), $key)); } if ($byte_limit !== null) { if (!is_string($const) || (strlen($const) > $byte_limit)) { throw new Exception( pht( '"%s" class "%s" has an invalid "%s" property. Field constants '. 'must be strings and no more than %s bytes in length.', __CLASS__, get_class($this), $key, new PhutilNumber($byte_limit))); } } return $const; } } diff --git a/src/parser/PhutilURI.php b/src/parser/PhutilURI.php index 7ddd3074..1902cd1f 100644 --- a/src/parser/PhutilURI.php +++ b/src/parser/PhutilURI.php @@ -1,559 +1,559 @@ protocol = $uri->protocol; $this->user = $uri->user; $this->pass = $uri->pass; $this->domain = $uri->domain; $this->port = $uri->port; $this->path = $uri->path; $this->query = $uri->query; $this->fragment = $uri->fragment; $this->type = $uri->type; $this->initializeQueryParams(phutil_string_cast($uri), $params); return; } $uri = phutil_string_cast($uri); $type = self::TYPE_URI; // Reject ambiguous URIs outright. Different versions of different clients // parse these in different ways. See T12526 for discussion. if (preg_match('(^[^/:]*://[^/]*[#?].*:)', $uri)) { throw new Exception( pht( 'Rejecting ambiguous URI "%s". This URI is not formatted or '. 'encoded properly.', $uri)); } $matches = null; if (preg_match('(^([^/:]*://[^/]*)(\\?.*)\z)', $uri, $matches)) { // If the URI is something like `idea://open?file=/path/to/file`, the // `parse_url()` function will parse `open?file=` as the host. This is // not the expected result. Break the URI into two pieces, stick a slash // in between them, parse that, then remove the path. See T6106. $parts = parse_url($matches[1].'/'.$matches[2]); unset($parts['path']); } else if ($this->isGitURIPattern($uri)) { // Handle Git/SCP URIs in the form "user@domain:relative/path". $user = '(?:(?P[^/@]+)@)?'; $host = '(?P[^/:]+)'; $path = ':(?P.*)'; $ok = preg_match('(^'.$user.$host.$path.'\z)', $uri, $matches); if (!$ok) { throw new Exception( pht( 'Failed to parse URI "%s" as a Git URI.', $uri)); } $parts = $matches; $parts['scheme'] = 'ssh'; $type = self::TYPE_GIT; } else { $parts = parse_url($uri); } // The parse_url() call will accept URIs with leading whitespace, but many // other tools (like git) will not. See T4913 for a specific example. If // the input string has leading whitespace, fail the parse. if ($parts) { if (ltrim($uri) != $uri) { $parts = false; } } // NOTE: `parse_url()` is very liberal about host names; fail the parse if // the host looks like garbage. In particular, we do not allow hosts which // begin with "." or "-". See T12961 for a specific attack which relied on // hosts beginning with "-". if ($parts) { $host = idx($parts, 'host', ''); if (strlen($host)) { if (!preg_match('/^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-]*\z/', $host)) { $parts = false; } } } if (!$parts) { $parts = array(); } // stringyness is to preserve API compatibility and // allow the tests to continue passing $this->protocol = idx($parts, 'scheme', ''); $this->user = rawurldecode(idx($parts, 'user', '')); $this->pass = rawurldecode(idx($parts, 'pass', '')); $this->domain = idx($parts, 'host', ''); $this->port = (string)idx($parts, 'port', ''); $this->path = idx($parts, 'path', ''); $query = idx($parts, 'query'); if ($query) { $pairs = id(new PhutilQueryStringParser()) ->parseQueryStringToPairList($query); foreach ($pairs as $pair) { list($key, $value) = $pair; $this->appendQueryParam($key, $value); } } $this->fragment = idx($parts, 'fragment', ''); $this->type = $type; $this->initializeQueryParams($uri, $params); } public function __toString() { $prefix = null; if ($this->isGitURI()) { $port = null; } else { $port = $this->port; } $domain = $this->domain; $user = $this->user; $pass = $this->pass; if (strlen($user) && strlen($pass)) { $auth = rawurlencode($user).':'.rawurlencode($pass).'@'; } else if (strlen($user)) { $auth = rawurlencode($user).'@'; } else { $auth = null; } $protocol = $this->protocol; if ($this->isGitURI()) { $protocol = null; } else { - if (strlen($auth)) { + if ($auth !== null) { $protocol = nonempty($this->protocol, 'http'); } } if (strlen($protocol) || strlen($auth) || strlen($domain)) { if ($this->isGitURI()) { $prefix = "{$auth}{$domain}"; } else { $prefix = "{$protocol}://{$auth}{$domain}"; } if (strlen($port)) { $prefix .= ':'.$port; } } if ($this->query) { $query = '?'.phutil_build_http_querystring_from_pairs($this->query); } else { $query = null; } if (strlen($this->getFragment())) { $fragment = '#'.$this->getFragment(); } else { $fragment = null; } $path = $this->getPath(); if ($this->isGitURI()) { if (strlen($path)) { $path = ':'.$path; } } return $prefix.$path.$query.$fragment; } /** * @deprecated */ public function setQueryParam($key, $value) { // To set, we replace the first matching key with the new value, then // remove all other matching keys. This replaces the old value and retains // the parameter order. $is_null = ($value === null); // Typecheck and cast the key before we compare it to existing keys. This // raises an early exception if the key has a bad type. list($key) = phutil_http_parameter_pair($key, ''); $found = false; foreach ($this->query as $list_key => $pair) { list($k, $v) = $pair; if ($k !== $key) { continue; } if ($found) { unset($this->query[$list_key]); continue; } $found = true; if ($is_null) { unset($this->query[$list_key]); } else { $this->insertQueryParam($key, $value, $list_key); } } $this->query = array_values($this->query); // If we didn't find an existing place to put it, add it to the end. if (!$found) { if (!$is_null) { $this->appendQueryParam($key, $value); } } return $this; } /** * @deprecated */ public function setQueryParams(array $params) { $this->query = array(); foreach ($params as $k => $v) { $this->appendQueryParam($k, $v); } return $this; } /** * @deprecated */ public function getQueryParams() { $map = array(); foreach ($this->query as $pair) { list($k, $v) = $pair; $map[$k] = $v; } return $map; } public function getQueryParamsAsMap() { $map = array(); foreach ($this->query as $pair) { list($k, $v) = $pair; if (isset($map[$k])) { throw new Exception( pht( 'Query parameters include a duplicate key ("%s") and can not be '. 'nondestructively represented as a map.', $k)); } $map[$k] = $v; } return $map; } public function getQueryParamsAsPairList() { return $this->query; } public function appendQueryParam($key, $value) { return $this->insertQueryParam($key, $value); } public function removeAllQueryParams() { $this->query = array(); return $this; } public function removeQueryParam($remove_key) { list($remove_key) = phutil_http_parameter_pair($remove_key, ''); foreach ($this->query as $idx => $pair) { list($key, $value) = $pair; if ($key !== $remove_key) { continue; } unset($this->query[$idx]); } $this->query = array_values($this->query); return $this; } public function replaceQueryParam($replace_key, $replace_value) { if ($replace_value === null) { throw new InvalidArgumentException( pht( 'Value provided to "replaceQueryParam()" for key "%s" is NULL. '. 'Use "removeQueryParam()" to remove a query parameter.', $replace_key)); } $this->removeQueryParam($replace_key); $this->appendQueryParam($replace_key, $replace_value); return $this; } private function insertQueryParam($key, $value, $idx = null) { list($key, $value) = phutil_http_parameter_pair($key, $value); if ($idx === null) { $this->query[] = array($key, $value); } else { $this->query[$idx] = array($key, $value); } return $this; } private function initializeQueryParams($uri, array $params) { $have_params = array(); foreach ($this->query as $pair) { list($key) = $pair; $have_params[$key] = true; } foreach ($params as $key => $value) { if (isset($have_params[$key])) { throw new InvalidArgumentException( pht( 'You are trying to construct an ambiguous URI: query parameter '. '"%s" is present in both the string argument ("%s") and the map '. 'argument.', $key, $uri)); } if ($value === null) { continue; } $this->appendQueryParam($key, $value); } return $this; } public function setProtocol($protocol) { $this->protocol = $protocol; return $this; } public function getProtocol() { return $this->protocol; } public function setDomain($domain) { $this->domain = $domain; return $this; } public function getDomain() { return $this->domain; } public function setPort($port) { $this->port = $port; return $this; } public function getPort() { return $this->port; } public function getPortWithProtocolDefault() { static $default_ports = array( 'http' => '80', 'https' => '443', 'ssh' => '22', ); return nonempty( $this->getPort(), idx($default_ports, $this->getProtocol()), ''); } public function setPath($path) { if ($this->isGitURI()) { // Git URIs use relative paths which do not need to begin with "/". } else { if ($this->domain && strlen($path) && $path[0] !== '/') { $path = '/'.$path; } } $this->path = $path; return $this; } public function appendPath($path) { $first = strlen($path) ? $path[0] : null; $last = strlen($this->path) ? $this->path[strlen($this->path) - 1] : null; if (!$this->path) { return $this->setPath($path); } else if ($first === '/' && $last === '/') { $path = substr($path, 1); } else if ($first !== '/' && $last !== '/') { $path = '/'.$path; } $this->path .= $path; return $this; } public function getPath() { return $this->path; } public function setFragment($fragment) { $this->fragment = $fragment; return $this; } public function getFragment() { return $this->fragment; } public function setUser($user) { $this->user = $user; return $this; } public function getUser() { return $this->user; } public function setPass($pass) { $this->pass = $pass; return $this; } public function getPass() { return $this->pass; } public function alter($key, $value) { $altered = clone $this; $altered->replaceQueryParam($key, $value); return $altered; } public function isGitURI() { return ($this->type == self::TYPE_GIT); } public function setType($type) { if ($type == self::TYPE_URI) { $path = $this->getPath(); if (strlen($path) && ($path[0] !== '/')) { // Try to catch this here because we are not allowed to throw from // inside __toString() so we don't have a reasonable opportunity to // react properly if we catch it later. throw new Exception( pht( 'Unable to convert URI "%s" into a standard URI because the '. 'path is relative. Standard URIs can not represent relative '. 'paths.', $this)); } } $this->type = $type; return $this; } public function getType() { return $this->type; } private function isGitURIPattern($uri) { $matches = null; $ok = preg_match('(^(?P[^/]+):(?P(?!//).*)\z)', $uri, $matches); if (!$ok) { return false; } $head = $matches['head']; $last = $matches['last']; // If any part of this has spaces in it, it's not a Git URI. We fail here // so we fall back and don't fail more abruptly later. if (preg_match('(\s)', $head.$last)) { return false; } // If the second part only contains digits, assume we're looking at // casually specified "domain.com:123" URI, not a Git URI pointed at an // entirely numeric relative path. if (preg_match('(^\d+\z)', $last)) { return false; } // If the first part has a "." or an "@" in it, interpret it as a domain // or a "user@host" string. if (preg_match('([.@])', $head)) { return true; } // Otherwise, interpret the URI conservatively as a "javascript:"-style // URI. This means that "localhost:path" is parsed as a normal URI instead // of a Git URI, but we can't tell which the user intends and it's safer // to treat it as a normal URI. return false; } } diff --git a/src/parser/argument/PhutilArgumentParser.php b/src/parser/argument/PhutilArgumentParser.php index 6fe5bc66..1bf31a23 100644 --- a/src/parser/argument/PhutilArgumentParser.php +++ b/src/parser/argument/PhutilArgumentParser.php @@ -1,1012 +1,1017 @@ setTagline('make an new dog') * $args->setSynopsis(<<parse( * array( * array( * 'name' => 'name', * 'param' => 'dogname', * 'default' => 'Rover', * 'help' => 'Set the dog\'s name. By default, the dog will be '. * 'named "Rover".', * ), * array( * 'name' => 'big', * 'short' => 'b', * 'help' => 'If set, create a large dog.', * ), * )); * * $dog_name = $args->getArg('name'); * $dog_size = $args->getArg('big') ? 'big' : 'small'; * * // ... etc ... * * (For detailed documentation on supported keys in argument specifications, * see @{class:PhutilArgumentSpecification}.) * * This will handle argument parsing, and generate appropriate usage help if * the user provides an unsupported flag. @{class:PhutilArgumentParser} also * supports some builtin "standard" arguments: * * $args->parseStandardArguments(); * * See @{method:parseStandardArguments} for details. Notably, this includes * a "--help" flag, and an "--xprofile" flag for profiling command-line scripts. * * Normally, when the parser encounters an unknown flag, it will exit with * an error. However, you can use @{method:parsePartial} to consume only a * set of flags: * * $args->parsePartial($spec_list); * * This allows you to parse some flags before making decisions about other * parsing, or share some flags across scripts. The builtin standard arguments * are implemented in this way. * * There is also builtin support for "workflows", which allow you to build a * script that operates in several modes (e.g., by accepting commands like * `install`, `upgrade`, etc), like `arc` does. For detailed documentation on * workflows, see @{class:PhutilArgumentWorkflow}. * * @task parse Parsing Arguments * @task read Reading Arguments * @task help Command Help * @task internal Internals */ final class PhutilArgumentParser extends Phobject { private $bin; private $argv; private $specs = array(); private $results = array(); private $parsed; private $tagline; private $synopsis; private $workflows; private $helpWorkflows; private $showHelp; private $requireArgumentTerminator = false; private $sawTerminator = false; const PARSE_ERROR_CODE = 77; private static $traceModeEnabled = false; /* -( Parsing Arguments )-------------------------------------------------- */ /** * Build a new parser. Generally, you start a script with: * * $args = new PhutilArgumentParser($argv); * * @param list Argument vector to parse, generally the $argv global. * @task parse */ public function __construct(array $argv) { $this->bin = $argv[0]; $this->argv = array_slice($argv, 1); } /** * Parse and consume a list of arguments, removing them from the argument * vector but leaving unparsed arguments for later consumption. You can * retrieve unconsumed arguments directly with * @{method:getUnconsumedArgumentVector}. Doing a partial parse can make it * easier to share common flags across scripts or workflows. * * @param list List of argument specs, see * @{class:PhutilArgumentSpecification}. * @param bool Require flags appear before any non-flag arguments. * @return this * @task parse */ public function parsePartial(array $specs, $initial_only = false) { return $this->parseInternal($specs, false, $initial_only); } /** * @return this */ private function parseInternal( array $specs, $correct_spelling, $initial_only) { $specs = PhutilArgumentSpecification::newSpecsFromList($specs); $this->mergeSpecs($specs); // Wildcard arguments have a name like "argv", but we don't want to // parse a corresponding flag like "--argv". Filter them out before // building a list of available flags. $non_wildcard = array(); foreach ($specs as $spec_key => $spec) { if ($spec->getWildcard()) { continue; } $non_wildcard[$spec_key] = $spec; } $specs_by_name = mpull($non_wildcard, null, 'getName'); $specs_by_short = mpull($non_wildcard, null, 'getShortAlias'); unset($specs_by_short[null]); $argv = $this->argv; $len = count($argv); $is_initial = true; for ($ii = 0; $ii < $len; $ii++) { $arg = $argv[$ii]; $map = null; $options = null; if (!is_string($arg)) { // Non-string argument; pass it through as-is. } else if ($arg == '--') { // This indicates "end of flags". $this->sawTerminator = true; break; } else if ($arg == '-') { // This is a normal argument (e.g., stdin). continue; } else if (!strncmp('--', $arg, 2)) { $pre = '--'; $arg = substr($arg, 2); $map = $specs_by_name; $options = array_keys($specs_by_name); } else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) { $pre = '-'; $arg = substr($arg, 1); $map = $specs_by_short; } else { $is_initial = false; } if ($map) { $val = null; $parts = explode('=', $arg, 2); if (count($parts) == 2) { list($arg, $val) = $parts; } // Try to correct flag spelling for full flags, to allow users to make // minor mistakes. if ($correct_spelling && $options && !isset($map[$arg])) { $corrections = PhutilArgumentSpellingCorrector::newFlagCorrector() ->correctSpelling($arg, $options); $should_autocorrect = $this->shouldAutocorrect(); if (count($corrections) == 1 && $should_autocorrect) { $corrected = head($corrections); $this->logMessage( tsprintf( "%s\n", pht( '(Assuming "%s" is the British spelling of "%s".)', $pre.$arg, $pre.$corrected))); $arg = $corrected; } } if (isset($map[$arg])) { if ($initial_only && !$is_initial) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" appears after the first non-flag argument. '. 'This special argument must appear before other arguments.', "{$pre}{$arg}")); } $spec = $map[$arg]; unset($argv[$ii]); $param_name = $spec->getParamName(); if ($val !== null) { if ($param_name === null) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" does not take a parameter.', "{$pre}{$arg}")); } } else { if ($param_name !== null) { if ($ii + 1 < $len) { $val = $argv[$ii + 1]; unset($argv[$ii + 1]); $ii++; } else { throw new PhutilArgumentUsageException( pht( 'Argument "%s" requires a parameter.', "{$pre}{$arg}")); } } else { $val = true; } } if (!$spec->getRepeatable()) { if (array_key_exists($spec->getName(), $this->results)) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" was provided twice.', "{$pre}{$arg}")); } } $conflicts = $spec->getConflicts(); foreach ($conflicts as $conflict => $reason) { if (array_key_exists($conflict, $this->results)) { if (!is_string($reason) || !strlen($reason)) { $reason = '.'; } else { $reason = ': '.$reason.'.'; } throw new PhutilArgumentUsageException( pht( 'Argument "%s" conflicts with argument "%s"%s', "{$pre}{$arg}", "--{$conflict}", $reason)); } } if ($spec->getRepeatable()) { if ($spec->getParamName() === null) { if (empty($this->results[$spec->getName()])) { $this->results[$spec->getName()] = 0; } $this->results[$spec->getName()]++; } else { $this->results[$spec->getName()][] = $val; } } else { $this->results[$spec->getName()] = $val; } } } } foreach ($specs as $spec) { if ($spec->getWildcard()) { $this->results[$spec->getName()] = $this->filterWildcardArgv($argv); $argv = array(); break; } } $this->argv = array_values($argv); return $this; } /** * Parse and consume a list of arguments, throwing an exception if there is * anything left unconsumed. This is like @{method:parsePartial}, but raises * a {class:PhutilArgumentUsageException} if there are leftovers. * * Normally, you would call @{method:parse} instead, which emits a * user-friendly error. You can also use @{method:printUsageException} to * render the exception in a user-friendly way. * * @param list List of argument specs, see * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parseFull(array $specs) { $this->parseInternal($specs, true, false); // If we have remaining unconsumed arguments other than a single "--", // fail. $argv = $this->filterWildcardArgv($this->argv); if ($argv) { throw new PhutilArgumentUsageException( pht( 'Unrecognized argument "%s".', head($argv))); } if ($this->getRequireArgumentTerminator()) { if (!$this->sawTerminator) { throw new ArcanistMissingArgumentTerminatorException(); } } if ($this->showHelp) { $this->printHelpAndExit(); } return $this; } /** * Parse and consume a list of arguments, raising a user-friendly error if * anything remains. See also @{method:parseFull} and @{method:parsePartial}. * * @param list List of argument specs, see * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parse(array $specs) { try { return $this->parseFull($specs); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(self::PARSE_ERROR_CODE); } } /** * Parse and execute workflows, raising a user-friendly error if anything * remains. See also @{method:parseWorkflowsFull}. * * See @{class:PhutilArgumentWorkflow} for details on using workflows. * * @param list List of argument specs, see * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parseWorkflows(array $workflows) { try { return $this->parseWorkflowsFull($workflows); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(self::PARSE_ERROR_CODE); } } /** * Select a workflow. For commands that may operate in several modes, like * `arc`, the modes can be split into "workflows". Each workflow specifies * the arguments it accepts. This method takes a list of workflows, selects * the chosen workflow, parses its arguments, and either executes it (if it * is executable) or returns it for handling. * * See @{class:PhutilArgumentWorkflow} for details on using workflows. * * @param list List of @{class:PhutilArgumentWorkflow}s. * @return PhutilArgumentWorkflow|no Returns the chosen workflow if it is * not executable, or executes it and * exits with a return code if it is. * @task parse */ public function parseWorkflowsFull(array $workflows) { assert_instances_of($workflows, 'PhutilArgumentWorkflow'); // Clear out existing workflows. We need to do this to permit the // construction of sub-workflows. $this->workflows = array(); foreach ($workflows as $workflow) { $name = $workflow->getName(); if ($name === null) { throw new PhutilArgumentSpecificationException( pht('Workflow has no name!')); } if (isset($this->workflows[$name])) { throw new PhutilArgumentSpecificationException( pht("Two workflows with name '%s!", $name)); } $this->workflows[$name] = $workflow; } $argv = $this->argv; if (empty($argv)) { // TODO: this is kind of hacky / magical. if (isset($this->workflows['help'])) { $argv = array('help'); } else { throw new PhutilArgumentUsageException(pht('No workflow selected.')); } } $flow = array_shift($argv); if (empty($this->workflows[$flow])) { $corrected = PhutilArgumentSpellingCorrector::newCommandCorrector() ->correctSpelling($flow, array_keys($this->workflows)); $should_autocorrect = $this->shouldAutocorrect(); if (count($corrected) == 1 && $should_autocorrect) { $corrected = head($corrected); $this->logMessage( tsprintf( "%s\n", pht( '(Assuming "%s" is the British spelling of "%s".)', $flow, $corrected))); $flow = $corrected; } else { if (!$this->showHelp) { $this->raiseUnknownWorkflow($flow, $corrected); } } } $workflow = idx($this->workflows, $flow); if ($this->showHelp) { // Make "cmd flow --help" behave like "cmd help flow", not "cmd help". $help_flow = idx($this->workflows, 'help'); if ($help_flow) { if ($help_flow !== $workflow) { $workflow = $help_flow; $argv = array($flow); // Prevent parse() from dumping us back out to standard help. $this->showHelp = false; } } else { $this->printHelpAndExit(); } } if (!$workflow) { $this->raiseUnknownWorkflow($flow, $corrected); } $this->argv = array_values($argv); if ($workflow->shouldParsePartial()) { $this->parsePartial($workflow->getArguments()); } else { $this->parse($workflow->getArguments()); } if ($workflow->isExecutable()) { $workflow->setArgv($this); $err = $workflow->execute($this); exit($err); } else { return $workflow; } } /** * Parse "standard" arguments and apply their effects: * * --trace Enable service call tracing. * --no-ansi Disable ANSI color/style sequences. * --xprofile Write out an XHProf profile. * --help Show help. * * @return this * * @phutil-external-symbol function xhprof_enable */ public function parseStandardArguments() { try { $this->parsePartial( array( array( 'name' => 'trace', 'help' => pht('Trace command execution and show service calls.'), 'standard' => true, ), array( 'name' => 'no-ansi', 'help' => pht( 'Disable ANSI terminal codes, printing plain text with '. 'no color or style.'), 'conflicts' => array( 'ansi' => null, ), 'standard' => true, ), array( 'name' => 'ansi', 'help' => pht( "Use formatting even in environments which probably ". "don't support it."), 'standard' => true, ), array( 'name' => 'xprofile', 'param' => 'profile', 'help' => pht( 'Profile script execution and write results to a file.'), 'standard' => true, ), array( 'name' => 'help', 'short' => 'h', 'help' => pht('Show this help.'), 'standard' => true, ), array( 'name' => 'show-standard-options', 'help' => pht( 'Show every option, including standard options like this one.'), 'standard' => true, ), array( 'name' => 'recon', 'help' => pht('Start in remote console mode.'), 'standard' => true, ), )); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(self::PARSE_ERROR_CODE); } if ($this->getArg('trace')) { PhutilServiceProfiler::installEchoListener(); self::$traceModeEnabled = true; } if ($this->getArg('no-ansi')) { PhutilConsoleFormatter::disableANSI(true); } if ($this->getArg('ansi')) { PhutilConsoleFormatter::disableANSI(false); } if ($this->getArg('help')) { $this->showHelp = true; } $xprofile = $this->getArg('xprofile'); if ($xprofile) { if (!function_exists('xhprof_enable')) { throw new Exception( pht('To use "--xprofile", you must install XHProf.')); } xhprof_enable(0); register_shutdown_function(array($this, 'shutdownProfiler')); } $recon = $this->getArg('recon'); if ($recon) { $remote_console = PhutilConsole::newRemoteConsole(); $remote_console->beginRedirectOut(); PhutilConsole::setConsole($remote_console); } else if ($this->getArg('trace')) { $server = new PhutilConsoleServer(); $server->setEnableLog(true); $console = PhutilConsole::newConsoleForServer($server); PhutilConsole::setConsole($console); } return $this; } /* -( Reading Arguments )-------------------------------------------------- */ public function getArg($name) { if (empty($this->specs[$name])) { throw new PhutilArgumentSpecificationException( pht('No specification exists for argument "%s"!', $name)); } if (idx($this->results, $name) !== null) { return $this->results[$name]; } return $this->specs[$name]->getDefault(); } public function getUnconsumedArgumentVector() { return $this->argv; } public function setUnconsumedArgumentVector(array $argv) { $this->argv = $argv; return $this; } public function setWorkflows($workflows) { $workflows = mpull($workflows, null, 'getName'); $this->workflows = $workflows; return $this; } public function setHelpWorkflows(array $help_workflows) { $help_workflows = mpull($help_workflows, null, 'getName'); $this->helpWorkflows = $help_workflows; return $this; } public function getWorkflows() { return $this->workflows; } /* -( Command Help )------------------------------------------------------- */ public function setRequireArgumentTerminator($require) { $this->requireArgumentTerminator = $require; return $this; } public function getRequireArgumentTerminator() { return $this->requireArgumentTerminator; } public function setSynopsis($synopsis) { $this->synopsis = $synopsis; return $this; } public function setTagline($tagline) { $this->tagline = $tagline; return $this; } public function printHelpAndExit() { echo $this->renderHelp(); exit(self::PARSE_ERROR_CODE); } public function renderHelp() { $out = array(); $more = array(); if ($this->bin) { $out[] = $this->format('**%s**', pht('NAME')); $name = $this->indent(6, '**%s**', basename($this->bin)); if ($this->tagline) { $name .= $this->format(' - '.$this->tagline); } $out[] = $name; $out[] = null; } if ($this->synopsis) { $out[] = $this->format('**%s**', pht('SYNOPSIS')); $out[] = $this->indent(6, $this->synopsis); $out[] = null; } $workflows = $this->helpWorkflows; if ($workflows === null) { $workflows = $this->workflows; } if ($workflows) { $has_help = false; $out[] = $this->format('**%s**', pht('WORKFLOWS')); $out[] = null; $flows = $workflows; ksort($flows); foreach ($flows as $workflow) { if ($workflow->getName() == 'help') { $has_help = true; } $out[] = $this->renderWorkflowHelp( $workflow->getName(), $show_details = false); } if ($has_help) { $more[] = pht( 'Use **%s** __command__ for a detailed command reference.', 'help'); } } $specs = $this->renderArgumentSpecs($this->specs); if ($specs) { $out[] = $this->format('**%s**', pht('OPTION REFERENCE')); $out[] = null; $out[] = $specs; } // If we have standard options but no --show-standard-options, print out // a quick hint about it. if (!empty($this->specs['show-standard-options']) && !$this->getArg('show-standard-options')) { $more[] = pht( 'Use __%s__ to show additional options.', '--show-standard-options'); } $out[] = null; if ($more) { foreach ($more as $hint) { $out[] = $this->indent(0, $hint); } $out[] = null; } return implode("\n", $out); } public function renderWorkflowHelp( $workflow_name, $show_details = false) { $out = array(); $indent = ($show_details ? 0 : 6); $workflows = $this->helpWorkflows; if ($workflows === null) { $workflows = $this->workflows; } $workflow = idx($workflows, strtolower($workflow_name)); if (!$workflow) { $out[] = $this->indent( $indent, pht('There is no **%s** workflow.', $workflow_name)); } else { $out[] = $this->indent($indent, $workflow->getExamples()); - $out[] = $this->indent($indent, $workflow->getSynopsis()); + + $synopsis = $workflow->getSynopsis(); + if ($synopsis !== null) { + $out[] = $this->indent($indent, $workflow->getSynopsis()); + } + if ($show_details) { $full_help = $workflow->getHelp(); if ($full_help) { $out[] = null; $out[] = $this->indent($indent, $full_help); } $specs = $this->renderArgumentSpecs($workflow->getArguments()); if ($specs) { $out[] = null; $out[] = $specs; } } } $out[] = null; return implode("\n", $out); } public function printUsageException(PhutilArgumentUsageException $ex) { $message = tsprintf( "**%s** %B\n", pht('Usage Exception:'), $ex->getMessage()); $this->logMessage($message); } private function logMessage($message) { fwrite(STDERR, $message); } /* -( Internals )---------------------------------------------------------- */ private function filterWildcardArgv(array $argv) { foreach ($argv as $key => $value) { if ($value == '--') { unset($argv[$key]); break; } else if ( is_string($value) && !strncmp($value, '-', 1) && strlen($value) > 1) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" is unrecognized. Use "%s" to indicate '. 'the end of flags.', $value, '--')); } } return array_values($argv); } private function mergeSpecs(array $specs) { $short_map = mpull($this->specs, null, 'getShortAlias'); unset($short_map[null]); $wildcard = null; foreach ($this->specs as $spec) { if ($spec->getWildcard()) { $wildcard = $spec; break; } } foreach ($specs as $spec) { $spec->validate(); $name = $spec->getName(); if (isset($this->specs[$name])) { throw new PhutilArgumentSpecificationException( pht( 'Two argument specifications have the same name ("%s").', $name)); } $short = $spec->getShortAlias(); if ($short) { if (isset($short_map[$short])) { throw new PhutilArgumentSpecificationException( pht( 'Two argument specifications have the same short alias ("%s").', $short)); } $short_map[$short] = $spec; } if ($spec->getWildcard()) { if ($wildcard) { throw new PhutilArgumentSpecificationException( pht( 'Two argument specifications are marked as wildcard arguments. '. 'You can have a maximum of one wildcard argument.')); } else { $wildcard = $spec; } } $this->specs[$name] = $spec; } foreach ($this->specs as $name => $spec) { foreach ($spec->getConflicts() as $conflict => $reason) { if (empty($this->specs[$conflict])) { throw new PhutilArgumentSpecificationException( pht( 'Argument "%s" conflicts with unspecified argument "%s".', $name, $conflict)); } if ($conflict == $name) { throw new PhutilArgumentSpecificationException( pht( 'Argument "%s" conflicts with itself!', $name)); } } } } private function renderArgumentSpecs(array $specs) { foreach ($specs as $key => $spec) { if ($spec->getWildcard()) { unset($specs[$key]); } } $out = array(); $no_standard_options = !empty($this->specs['show-standard-options']) && !$this->getArg('show-standard-options'); $specs = msort($specs, 'getName'); foreach ($specs as $spec) { if ($spec->getStandard() && $no_standard_options) { // If this is a standard argument and the user didn't pass // --show-standard-options, skip it. continue; } $name = $this->indent(6, '__--%s__', $spec->getName()); $short = null; if ($spec->getShortAlias()) { $short = $this->format(', __-%s__', $spec->getShortAlias()); } if ($spec->getParamName()) { $param = $this->format(' __%s__', $spec->getParamName()); $name .= $param; if ($short) { $short .= $param; } } $out[] = $name.$short; $out[] = $this->indent(10, $spec->getHelp()); $out[] = null; } return implode("\n", $out); } private function format($str /* , ... */) { $args = func_get_args(); return call_user_func_array( 'phutil_console_format', $args); } private function indent($level, $str /* , ... */) { $args = func_get_args(); $args = array_slice($args, 1); $text = call_user_func_array(array($this, 'format'), $args); return phutil_console_wrap($text, $level); } /** * @phutil-external-symbol function xhprof_disable */ public function shutdownProfiler() { $data = xhprof_disable(); $data = json_encode($data); Filesystem::writeFile($this->getArg('xprofile'), $data); } public static function isTraceModeEnabled() { return self::$traceModeEnabled; } private function raiseUnknownWorkflow($flow, array $maybe) { if ($maybe) { sort($maybe); $maybe_list = id(new PhutilConsoleList()) ->setWrap(false) ->setBullet(null) ->addItems($maybe) ->drawConsoleString(); $message = tsprintf( "%B\n%B", pht( 'Invalid command "%s". Did you mean:', $flow), $maybe_list); } else { $names = mpull($this->workflows, 'getName'); sort($names); $message = tsprintf( '%B', pht( 'Invalid command "%s". Valid commands are: %s.', $flow, implode(', ', $names))); } if (isset($this->workflows['help'])) { $binary = basename($this->bin); $message = tsprintf( "%B\n%s", $message, pht( 'For details on available commands, run "%s".', "{$binary} help")); } throw new PhutilArgumentUsageException($message); } private function shouldAutocorrect() { return !phutil_is_noninteractive(); } } diff --git a/src/symbols/PhutilClassMapQuery.php b/src/symbols/PhutilClassMapQuery.php index 4b947a61..62d567d6 100644 --- a/src/symbols/PhutilClassMapQuery.php +++ b/src/symbols/PhutilClassMapQuery.php @@ -1,338 +1,338 @@ ancestorClass = $class; return $this; } /** * Provide a method to select a unique key for each instance. * * If you provide a method here, the map will be keyed with these values, * instead of with class names. Exceptions will be raised if entries are * not unique. * * You must provide a method here to use @{method:setExpandMethod}. * * @param string Name of the unique key method. * @param bool If true, then classes which return `null` will be filtered * from the results. * @return this * @task config */ public function setUniqueMethod($unique_method, $filter_null = false) { $this->uniqueMethod = $unique_method; $this->filterNull = $filter_null; return $this; } /** * Provide a method to expand each concrete subclass into available instances. * * With some class maps, each class is allowed to provide multiple entries * in the map by returning alternatives from some method with a default * implementation like this: * * public function generateVariants() { * return array($this); * } * * For example, a "color" class may really generate and configure several * instances in the final class map: * * public function generateVariants() { * return array( * self::newColor('red'), * self::newColor('green'), * self::newColor('blue'), * ); * } * * This allows multiple entires in the final map to share an entire * implementation, rather than requiring that they each have their own unique * subclass. * * This pattern is most useful if several variants are nearly identical (so * the stub subclasses would be essentially empty) or the available variants * are driven by configuration. * * If a class map uses this pattern, it must also provide a unique key for * each instance with @{method:setUniqueMethod}. * * @param string Name of the expansion method. * @return this * @task config */ public function setExpandMethod($expand_method) { $this->expandMethod = $expand_method; return $this; } /** * Provide a method to sort the final map. * * The map will be sorted using @{function:msort} and passing this method * name. * * @param string Name of the sorting method. * @return this * @task config */ public function setSortMethod($sort_method) { $this->sortMethod = $sort_method; return $this; } /** * Provide a method to filter the map. * * @param string Name of the filtering method. * @return this * @task config */ public function setFilterMethod($filter_method) { $this->filterMethod = $filter_method; return $this; } public function setContinueOnFailure($continue) { $this->continueOnFailure = $continue; return $this; } /* -( Executing the Query )------------------------------------------------ */ /** * Execute the query as configured. * * @return map Realized class map. * @task exec */ public function execute() { $cache_key = $this->getCacheKey(); if (!isset(self::$cache[$cache_key])) { self::$cache[$cache_key] = $this->loadMap(); } return self::$cache[$cache_key]; } /** * Delete all class map caches. * * @return void * @task exec */ public static function deleteCaches() { self::$cache = array(); } /** * Generate the core query results. * * This method is used to fill the cache. * * @return map Realized class map. * @task exec */ private function loadMap() { $ancestor = $this->ancestorClass; if (!strlen($ancestor)) { throw new PhutilInvalidStateException('setAncestorClass'); } if (!class_exists($ancestor) && !interface_exists($ancestor)) { throw new Exception( pht( 'Trying to execute a class map query for descendants of class '. '"%s", but no such class or interface exists.', $ancestor)); } $expand = $this->expandMethod; $filter = $this->filterMethod; $unique = $this->uniqueMethod; $sort = $this->sortMethod; - if (strlen($expand)) { - if (!strlen($unique)) { + if ($expand !== null) { + if ($unique === null) { throw new Exception( pht( 'Trying to execute a class map query for descendants of class '. '"%s", but the query specifies an "expand method" ("%s") without '. 'specifying a "unique method". Class maps which support expansion '. 'must have unique keys.', $ancestor, $expand)); } } $objects = id(new PhutilSymbolLoader()) ->setAncestorClass($ancestor) ->setContinueOnFailure($this->continueOnFailure) ->loadObjects(); // Apply the "expand" mechanism, if it is configured. - if (strlen($expand)) { + if ($expand !== null) { $list = array(); foreach ($objects as $object) { foreach (call_user_func(array($object, $expand)) as $instance) { $list[] = $instance; } } } else { $list = $objects; } // Apply the "unique" mechanism, if it is configured. - if (strlen($unique)) { + if ($unique !== null) { $map = array(); foreach ($list as $object) { $key = call_user_func(array($object, $unique)); if ($key === null && $this->filterNull) { continue; } if (empty($map[$key])) { $map[$key] = $object; continue; } throw new Exception( pht( 'Two objects (of classes "%s" and "%s", descendants of ancestor '. 'class "%s") returned the same key from "%s" ("%s"), but each '. 'object in this class map must be identified by a unique key.', get_class($object), get_class($map[$key]), $ancestor, $unique.'()', $key)); } } else { $map = $list; } // Apply the "filter" mechanism, if it is configured. - if (strlen($filter)) { + if ($filter !== null) { $map = mfilter($map, $filter); } // Apply the "sort" mechanism, if it is configured. - if (strlen($sort)) { + if ($sort !== null) { if ($map) { // The "sort" method may return scalars (which we want to sort with // "msort()"), or may return PhutilSortVector objects (which we want // to sort with "msortv()"). $item = call_user_func(array(head($map), $sort)); // Since we may be early in the stack, use a string to avoid triggering // autoload in old versions of PHP. $vector_class = 'PhutilSortVector'; if ($item instanceof $vector_class) { $map = msortv($map, $sort); } else { $map = msort($map, $sort); } } } return $map; } /* -( Managing the Map Cache )--------------------------------------------- */ /** * Return a cache key for this query. * * @return string Cache key. * @task cache */ public function getCacheKey() { $parts = array( $this->ancestorClass, $this->uniqueMethod, $this->filterNull, $this->expandMethod, $this->filterMethod, $this->sortMethod, ); return implode(':', $parts); } } diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php index 08362db2..43ed0c0e 100644 --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -1,2470 +1,2470 @@ toolset = $toolset; return $this; } final public function getToolset() { return $this->toolset; } final public function setRuntime(ArcanistRuntime $runtime) { $this->runtime = $runtime; return $this; } final public function getRuntime() { return $this->runtime; } final public function setConfigurationEngine( ArcanistConfigurationEngine $engine) { $this->configurationEngine = $engine; return $this; } final public function getConfigurationEngine() { return $this->configurationEngine; } final public function setConfigurationSourceList( ArcanistConfigurationSourceList $list) { $this->configurationSourceList = $list; return $this; } final public function getConfigurationSourceList() { return $this->configurationSourceList; } public function newPhutilWorkflow() { $arguments = $this->getWorkflowArguments(); assert_instances_of($arguments, 'ArcanistWorkflowArgument'); $specs = mpull($arguments, 'getPhutilSpecification'); $phutil_workflow = id(new ArcanistPhutilWorkflow()) ->setName($this->getWorkflowName()) ->setWorkflow($this) ->setArguments($specs); $information = $this->getWorkflowInformation(); if ($information !== null) { if (!($information instanceof ArcanistWorkflowInformation)) { throw new Exception( pht( 'Expected workflow ("%s", of class "%s") to return an '. '"ArcanistWorkflowInformation" object from call to '. '"getWorkflowInformation()", got %s.', $this->getWorkflowName(), get_class($this), phutil_describe_type($information))); } } if ($information) { $synopsis = $information->getSynopsis(); - if (strlen($synopsis)) { + if ($synopsis !== null) { $phutil_workflow->setSynopsis($synopsis); } $examples = $information->getExamples(); if ($examples) { $examples = implode("\n", $examples); $phutil_workflow->setExamples($examples); } $help = $information->getHelp(); if (strlen($help)) { // Unwrap linebreaks in the help text so we don't get weird formatting. $help = preg_replace("/(?<=\S)\n(?=\S)/", ' ', $help); $phutil_workflow->setHelp($help); } } return $phutil_workflow; } final public function newLegacyPhutilWorkflow() { $phutil_workflow = id(new ArcanistPhutilWorkflow()) ->setName($this->getWorkflowName()); $arguments = $this->getArguments(); $specs = array(); foreach ($arguments as $key => $argument) { if ($key == '*') { $key = $argument; $argument = array( 'wildcard' => true, ); } unset($argument['paramtype']); unset($argument['supports']); unset($argument['nosupport']); unset($argument['passthru']); unset($argument['conflict']); $spec = array( 'name' => $key, ) + $argument; $specs[] = $spec; } $phutil_workflow->setArguments($specs); $synopses = $this->getCommandSynopses(); $phutil_workflow->setSynopsis($synopses); $help = $this->getCommandHelp(); if (strlen($help)) { $phutil_workflow->setHelp($help); } return $phutil_workflow; } final protected function newWorkflowArgument($key) { return id(new ArcanistWorkflowArgument()) ->setKey($key); } final protected function newWorkflowInformation() { return new ArcanistWorkflowInformation(); } final public function executeWorkflow(PhutilArgumentParser $args) { $runtime = $this->getRuntime(); $this->arguments = $args; $caught = null; $runtime->pushWorkflow($this); try { $err = $this->runWorkflow($args); } catch (Exception $ex) { $caught = $ex; } try { $this->runWorkflowCleanup(); } catch (Exception $ex) { phlog($ex); } $runtime->popWorkflow(); if ($caught) { throw $caught; } return $err; } final public function getLogEngine() { return $this->getRuntime()->getLogEngine(); } protected function runWorkflowCleanup() { // TOOLSETS: Do we need this? return; } public function __construct() {} public function run() { throw new PhutilMethodNotImplementedException(); } /** * Finalizes any cleanup operations that need to occur regardless of * whether the command succeeded or failed. */ public function finalize() { $this->finalizeWorkingCopy(); } /** * Return the command used to invoke this workflow from the command like, * e.g. "help" for @{class:ArcanistHelpWorkflow}. * * @return string The command a user types to invoke this workflow. */ abstract public function getWorkflowName(); /** * Return console formatted string with all command synopses. * * @return string 6-space indented list of available command synopses. */ public function getCommandSynopses() { return array(); } /** * Return console formatted string with command help printed in `arc help`. * * @return string 10-space indented help to use the command. */ public function getCommandHelp() { return null; } public function supportsToolset(ArcanistToolset $toolset) { return false; } /* -( Conduit )------------------------------------------------------------ */ /** * Set the URI which the workflow will open a conduit connection to when * @{method:establishConduit} is called. Arcanist makes an effort to set * this by default for all workflows (by reading ##.arcconfig## and/or the * value of ##--conduit-uri##) even if they don't need Conduit, so a workflow * can generally upgrade into a conduit workflow later by just calling * @{method:establishConduit}. * * You generally should not need to call this method unless you are * specifically overriding the default URI. It is normally sufficient to * just invoke @{method:establishConduit}. * * NOTE: You can not call this after a conduit has been established. * * @param string The URI to open a conduit to when @{method:establishConduit} * is called. * @return this * @task conduit */ final public function setConduitURI($conduit_uri) { if ($this->conduit) { throw new Exception( pht( 'You can not change the Conduit URI after a '. 'conduit is already open.')); } $this->conduitURI = $conduit_uri; return $this; } /** * Returns the URI the conduit connection within the workflow uses. * * @return string * @task conduit */ final public function getConduitURI() { return $this->conduitURI; } /** * Open a conduit channel to the server which was previously configured by * calling @{method:setConduitURI}. Arcanist will do this automatically if * the workflow returns ##true## from @{method:requiresConduit}, or you can * later upgrade a workflow and build a conduit by invoking it manually. * * You must establish a conduit before you can make conduit calls. * * NOTE: You must call @{method:setConduitURI} before you can call this * method. * * @return this * @task conduit */ final public function establishConduit() { if ($this->conduit) { return $this; } if (!$this->conduitURI) { throw new Exception( pht( 'You must specify a Conduit URI with %s before you can '. 'establish a conduit.', 'setConduitURI()')); } $this->conduit = new ConduitClient($this->conduitURI); if ($this->conduitTimeout) { $this->conduit->setTimeout($this->conduitTimeout); } return $this; } final public function getConfigFromAnySource($key) { $source_list = $this->getConfigurationSourceList(); if ($source_list) { $value_list = $source_list->getStorageValueList($key); if ($value_list) { return last($value_list)->getValue(); } return null; } return $this->configurationManager->getConfigFromAnySource($key); } /** * Set credentials which will be used to authenticate against Conduit. These * credentials can then be used to establish an authenticated connection to * conduit by calling @{method:authenticateConduit}. Arcanist sets some * defaults for all workflows regardless of whether or not they return true * from @{method:requireAuthentication}, based on the ##~/.arcrc## and * ##.arcconf## files if they are present. Thus, you can generally upgrade a * workflow which does not require authentication into an authenticated * workflow by later invoking @{method:requireAuthentication}. You should not * normally need to call this method unless you are specifically overriding * the defaults. * * NOTE: You can not call this method after calling * @{method:authenticateConduit}. * * @param dict A credential dictionary, see @{method:authenticateConduit}. * @return this * @task conduit */ final public function setConduitCredentials(array $credentials) { if ($this->isConduitAuthenticated()) { throw new Exception( pht('You may not set new credentials after authenticating conduit.')); } $this->conduitCredentials = $credentials; return $this; } /** * Get the protocol version the client should identify with. * * @return int Version the client should claim to be. * @task conduit */ final public function getConduitVersion() { return 6; } /** * Open and authenticate a conduit connection to a Phabricator server using * provided credentials. Normally, Arcanist does this for you automatically * when you return true from @{method:requiresAuthentication}, but you can * also upgrade an existing workflow to one with an authenticated conduit * by invoking this method manually. * * You must authenticate the conduit before you can make authenticated conduit * calls (almost all calls require authentication). * * This method uses credentials provided via @{method:setConduitCredentials} * to authenticate to the server: * * - ##user## (required) The username to authenticate with. * - ##certificate## (required) The Conduit certificate to use. * - ##description## (optional) Description of the invoking command. * * Successful authentication allows you to call @{method:getUserPHID} and * @{method:getUserName}, as well as use the client you access with * @{method:getConduit} to make authenticated calls. * * NOTE: You must call @{method:setConduitURI} and * @{method:setConduitCredentials} before you invoke this method. * * @return this * @task conduit */ final public function authenticateConduit() { if ($this->isConduitAuthenticated()) { return $this; } $this->establishConduit(); $credentials = $this->conduitCredentials; try { if (!$credentials) { throw new Exception( pht( 'Set conduit credentials with %s before authenticating conduit!', 'setConduitCredentials()')); } // If we have `token`, this server supports the simpler, new-style // token-based authentication. Use that instead of all the certificate // stuff. $token = idx($credentials, 'token'); if (strlen($token)) { $conduit = $this->getConduit(); $conduit->setConduitToken($token); try { $result = $this->getConduit()->callMethodSynchronous( 'user.whoami', array()); $this->userName = $result['userName']; $this->userPHID = $result['phid']; $this->conduitAuthenticated = true; return $this; } catch (Exception $ex) { $conduit->setConduitToken(null); throw $ex; } } if (empty($credentials['user'])) { throw new ConduitClientException( 'ERR-INVALID-USER', pht('Empty user in credentials.')); } if (empty($credentials['certificate'])) { throw new ConduitClientException( 'ERR-NO-CERTIFICATE', pht('Empty certificate in credentials.')); } $description = idx($credentials, 'description', ''); $user = $credentials['user']; $certificate = $credentials['certificate']; $connection = $this->getConduit()->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => $this->getConduitVersion(), 'clientDescription' => php_uname('n').':'.$description, 'user' => $user, 'certificate' => $certificate, 'host' => $this->conduitURI, )); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' || $ex->getErrorCode() == 'ERR-INVALID-USER' || $ex->getErrorCode() == 'ERR-INVALID-AUTH') { $conduit_uri = $this->conduitURI; $message = phutil_console_format( "\n%s\n\n %s\n\n%s\n%s", pht('YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR'), pht('To do this, run: **%s**', 'arc install-certificate'), pht("The server '%s' rejected your request:", $conduit_uri), $ex->getMessage()); throw new ArcanistUsageException($message); } else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') { // Cleverly disguise this as being AWESOME!!! echo phutil_console_format("**%s**\n\n", pht('New Version Available!')); echo phutil_console_wrap($ex->getMessage()); echo "\n\n"; echo pht('In most cases, arc can be upgraded automatically.')."\n"; $ok = phutil_console_confirm( pht('Upgrade arc now?'), $default_no = false); if (!$ok) { throw $ex; } $root = dirname(phutil_get_library_root('arcanist')); chdir($root); $err = phutil_passthru('%s upgrade', $root.'/bin/arc'); if (!$err) { echo "\n".pht('Try running your arc command again.')."\n"; } exit(1); } else { throw $ex; } } $this->userName = $user; $this->userPHID = $connection['userPHID']; $this->conduitAuthenticated = true; return $this; } /** * @return bool True if conduit is authenticated, false otherwise. * @task conduit */ final protected function isConduitAuthenticated() { return (bool)$this->conduitAuthenticated; } /** * Override this to return true if your workflow requires a conduit channel. * Arc will build the channel for you before your workflow executes. This * implies that you only need an unauthenticated channel; if you need * authentication, override @{method:requiresAuthentication}. * * @return bool True if arc should build a conduit channel before running * the workflow. * @task conduit */ public function requiresConduit() { return false; } /** * Override this to return true if your workflow requires an authenticated * conduit channel. This implies that it requires a conduit. Arc will build * and authenticate the channel for you before the workflow executes. * * @return bool True if arc should build an authenticated conduit channel * before running the workflow. * @task conduit */ public function requiresAuthentication() { return false; } /** * Returns the PHID for the user once they've authenticated via Conduit. * * @return phid Authenticated user PHID. * @task conduit */ final public function getUserPHID() { if (!$this->userPHID) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires authentication, override ". "%s to return true.", $workflow, 'requiresAuthentication()')); } return $this->userPHID; } /** * Return the username for the user once they've authenticated via Conduit. * * @return string Authenticated username. * @task conduit */ final public function getUserName() { return $this->userName; } /** * Get the established @{class@libphutil:ConduitClient} in order to make * Conduit method calls. Before the client is available it must be connected, * either implicitly by making @{method:requireConduit} or * @{method:requireAuthentication} return true, or explicitly by calling * @{method:establishConduit} or @{method:authenticateConduit}. * * @return @{class@libphutil:ConduitClient} Live conduit client. * @task conduit */ final public function getConduit() { if (!$this->conduit) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a Conduit, override ". "%s to return true.", $workflow, 'requiresConduit()')); } return $this->conduit; } final public function setArcanistConfiguration( ArcanistConfiguration $arcanist_configuration) { $this->arcanistConfiguration = $arcanist_configuration; return $this; } final public function getArcanistConfiguration() { return $this->arcanistConfiguration; } final public function setConfigurationManager( ArcanistConfigurationManager $arcanist_configuration_manager) { $this->configurationManager = $arcanist_configuration_manager; return $this; } final public function getConfigurationManager() { return $this->configurationManager; } public function requiresWorkingCopy() { return false; } public function desiresWorkingCopy() { return false; } public function requiresRepositoryAPI() { return false; } public function desiresRepositoryAPI() { return false; } final public function setCommand($command) { $this->command = $command; return $this; } final public function getCommand() { return $this->command; } public function getArguments() { return array(); } final public function setWorkingDirectory($working_directory) { $this->workingDirectory = $working_directory; return $this; } final public function getWorkingDirectory() { return $this->workingDirectory; } private function setParentWorkflow($parent_workflow) { $this->parentWorkflow = $parent_workflow; return $this; } final protected function getParentWorkflow() { return $this->parentWorkflow; } final public function buildChildWorkflow($command, array $argv) { $arc_config = $this->getArcanistConfiguration(); $workflow = $arc_config->buildWorkflow($command); $workflow->setParentWorkflow($this); $workflow->setConduitEngine($this->getConduitEngine()); $workflow->setCommand($command); $workflow->setConfigurationManager($this->getConfigurationManager()); if ($this->repositoryAPI) { $workflow->setRepositoryAPI($this->repositoryAPI); } if ($this->userPHID) { $workflow->userPHID = $this->getUserPHID(); $workflow->userName = $this->getUserName(); } if ($this->conduit) { $workflow->conduit = $this->conduit; $workflow->setConduitCredentials($this->conduitCredentials); $workflow->conduitAuthenticated = $this->conduitAuthenticated; } $workflow->setArcanistConfiguration($arc_config); $workflow->parseArguments(array_values($argv)); return $workflow; } final public function getArgument($key, $default = null) { // TOOLSETS: Remove this legacy code. if (is_array($this->arguments)) { return idx($this->arguments, $key, $default); } return $this->arguments->getArg($key); } final public function getCompleteArgumentSpecification() { $spec = $this->getArguments(); $arc_config = $this->getArcanistConfiguration(); $command = $this->getCommand(); $spec += $arc_config->getCustomArgumentsForCommand($command); return $spec; } final public function parseArguments(array $args) { $spec = $this->getCompleteArgumentSpecification(); $dict = array(); $more_key = null; if (!empty($spec['*'])) { $more_key = $spec['*']; unset($spec['*']); $dict[$more_key] = array(); } $short_to_long_map = array(); foreach ($spec as $long => $options) { if (!empty($options['short'])) { $short_to_long_map[$options['short']] = $long; } } foreach ($spec as $long => $options) { if (!empty($options['repeat'])) { $dict[$long] = array(); } } $more = array(); $size = count($args); for ($ii = 0; $ii < $size; $ii++) { $arg = $args[$ii]; $arg_name = null; $arg_key = null; if ($arg == '--') { $more = array_merge( $more, array_slice($args, $ii + 1)); break; } else if (!strncmp($arg, '--', 2)) { $arg_key = substr($arg, 2); $parts = explode('=', $arg_key, 2); if (count($parts) == 2) { list($arg_key, $val) = $parts; array_splice($args, $ii, 1, array('--'.$arg_key, $val)); $size++; } if (!array_key_exists($arg_key, $spec)) { $corrected = PhutilArgumentSpellingCorrector::newFlagCorrector() ->correctSpelling($arg_key, array_keys($spec)); if (count($corrected) == 1) { PhutilConsole::getConsole()->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", '--'.$arg_key, '--'.head($corrected))."\n"); $arg_key = head($corrected); } else { throw new ArcanistUsageException( pht( "Unknown argument '%s'. Try '%s'.", $arg_key, 'arc help')); } } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException( pht( "Unknown argument '%s'. Try '%s'.", $arg_key, 'arc help')); } $arg_key = $short_to_long_map[$arg_key]; } else { $more[] = $arg; continue; } $options = $spec[$arg_key]; if (empty($options['param'])) { $dict[$arg_key] = true; } else { if ($ii == $size - 1) { throw new ArcanistUsageException( pht( "Option '%s' requires a parameter.", $arg)); } if (!empty($options['repeat'])) { $dict[$arg_key][] = $args[$ii + 1]; } else { $dict[$arg_key] = $args[$ii + 1]; } $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException( pht( "Unrecognized argument '%s'. Try '%s'.", $example, 'arc help')); } } foreach ($dict as $key => $value) { if (empty($spec[$key]['conflicts'])) { continue; } foreach ($spec[$key]['conflicts'] as $conflict => $more) { if (isset($dict[$conflict])) { if ($more) { $more = ': '.$more; } else { $more = '.'; } // TODO: We'll always display these as long-form, when the user might // have typed them as short form. throw new ArcanistUsageException( pht( "Arguments '%s' and '%s' are mutually exclusive", "--{$key}", "--{$conflict}").$more); } } } $this->arguments = $dict; $this->didParseArguments(); return $this; } protected function didParseArguments() { // Override this to customize workflow argument behavior. } final public function getWorkingCopy() { $configuration_engine = $this->getConfigurationEngine(); // TOOLSETS: Remove this once all workflows are toolset workflows. if (!$configuration_engine) { throw new Exception( pht( 'This workflow has not yet been updated to Toolsets and can '. 'not retrieve a modern WorkingCopy object. Use '. '"getWorkingCopyIdentity()" to retrieve a previous-generation '. 'object.')); } return $configuration_engine->getWorkingCopy(); } final public function getWorkingCopyIdentity() { $configuration_engine = $this->getConfigurationEngine(); if ($configuration_engine) { $working_copy = $configuration_engine->getWorkingCopy(); $working_path = $working_copy->getWorkingDirectory(); return ArcanistWorkingCopyIdentity::newFromPath($working_path); } $working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity(); if (!$working_copy) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a working copy, override ". "%s to return true.", $workflow, 'requiresWorkingCopy()')); } return $working_copy; } final public function setRepositoryAPI($api) { $this->repositoryAPI = $api; return $this; } final public function hasRepositoryAPI() { try { return (bool)$this->getRepositoryAPI(); } catch (Exception $ex) { return false; } } final public function getRepositoryAPI() { $configuration_engine = $this->getConfigurationEngine(); if ($configuration_engine) { $working_copy = $configuration_engine->getWorkingCopy(); return $working_copy->getRepositoryAPI(); } if (!$this->repositoryAPI) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a Repository API, override ". "%s to return true.", $workflow, 'requiresRepositoryAPI()')); } return $this->repositoryAPI; } final protected function shouldRequireCleanUntrackedFiles() { return empty($this->arguments['allow-untracked']); } final public function setCommitMode($mode) { $this->commitMode = $mode; return $this; } final public function finalizeWorkingCopy() { if ($this->stashed) { $api = $this->getRepositoryAPI(); $api->unstashChanges(); echo pht('Restored stashed changes to the working directory.')."\n"; } } final public function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $must_commit = array(); $working_copy_desc = phutil_console_format( " %s: __%s__\n\n", pht('Working copy'), $api->getPath()); // NOTE: this is a subversion-only concept. $incomplete = $api->getIncompleteChanges(); if ($incomplete) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s\n\n%s", pht( "You have incompletely checked out directories in this working ". "copy. Fix them before proceeding.'"), $working_copy_desc, pht('Incomplete directories in working copy:'), implode("\n ", $incomplete), pht( "You can fix these paths by running '%s' on them.", 'svn update'))); } $conflicts = $api->getMergeConflicts(); if ($conflicts) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s", pht( 'You have merge conflicts in this working copy. Resolve merge '. 'conflicts before proceeding.'), $working_copy_desc, pht('Conflicts in working copy:'), implode("\n ", $conflicts))); } $missing = $api->getMissingChanges(); if ($missing) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s\n", pht( 'You have missing files in this working copy. Revert or formally '. 'remove them (with `%s`) before proceeding.', 'svn rm'), $working_copy_desc, pht('Missing files in working copy:'), implode("\n ", $missing))); } $externals = $api->getDirtyExternalChanges(); // TODO: This state can exist in Subversion, but it is currently handled // elsewhere. It should probably be handled here, eventually. if ($api instanceof ArcanistSubversionAPI) { $externals = array(); } if ($externals) { $message = pht( '%s submodule(s) have uncommitted or untracked changes:', new PhutilNumber(count($externals))); $prompt = pht( 'Ignore the changes to these %s submodule(s) and continue?', new PhutilNumber(count($externals))); $list = id(new PhutilConsoleList()) ->setWrap(false) ->addItems($externals); id(new PhutilConsoleBlock()) ->addParagraph($message) ->addList($list) ->draw(); $ok = phutil_console_confirm($prompt, $default_no = false); if (!$ok) { throw new ArcanistUserAbortException(); } } $uncommitted = $api->getUncommittedChanges(); $unstaged = $api->getUnstagedChanges(); // We already dealt with externals. $unstaged = array_diff($unstaged, $externals); // We only want files which are purely uncommitted. $uncommitted = array_diff($uncommitted, $unstaged); $uncommitted = array_diff($uncommitted, $externals); $untracked = $api->getUntrackedChanges(); if (!$this->shouldRequireCleanUntrackedFiles()) { $untracked = array(); } if ($untracked) { echo sprintf( "%s\n\n%s", pht('You have untracked files in this working copy.'), $working_copy_desc); if ($api instanceof ArcanistGitAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), '.git/info/exclude'); } else if ($api instanceof ArcanistSubversionAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), 'svn:ignore'); } else if ($api instanceof ArcanistMercurialAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), '.hgignore'); } $untracked_list = " ".implode("\n ", $untracked); echo sprintf( " %s\n %s\n%s", pht('Untracked changes in working copy:'), $hint, $untracked_list); $prompt = pht( 'Ignore these %s untracked file(s) and continue?', phutil_count($untracked)); if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } $should_commit = false; if ($unstaged || $uncommitted) { // NOTE: We're running this because it builds a cache and can take a // perceptible amount of time to arrive at an answer, but we don't want // to pause in the middle of printing the output below. $this->getShouldAmend(); echo sprintf( "%s\n\n%s", pht('You have uncommitted changes in this working copy.'), $working_copy_desc); $lists = array(); if ($unstaged) { $unstaged_list = " ".implode("\n ", $unstaged); $lists[] = sprintf( " %s\n%s", pht('Unstaged changes in working copy:'), $unstaged_list); } if ($uncommitted) { $uncommitted_list = " ".implode("\n ", $uncommitted); $lists[] = sprintf( "%s\n%s", pht('Uncommitted changes in working copy:'), $uncommitted_list); } echo implode("\n\n", $lists)."\n"; $all_uncommitted = array_merge($unstaged, $uncommitted); if ($this->askForAdd($all_uncommitted)) { if ($unstaged) { $api->addToCommit($unstaged); } $should_commit = true; } else { $permit_autostash = $this->getConfigFromAnySource('arc.autostash'); if ($permit_autostash && $api->canStashChanges()) { echo pht( 'Stashing uncommitted changes. (You can restore them with `%s`).', 'git stash pop')."\n"; $api->stashChanges(); $this->stashed = true; } else { throw new ArcanistUsageException( pht( 'You can not continue with uncommitted changes. '. 'Commit or discard them before proceeding.')); } } } if ($should_commit) { if ($this->getShouldAmend()) { $commit = head($api->getLocalCommitInformation()); $api->amendCommit($commit['message']); } else if ($api->supportsLocalCommits()) { $template = sprintf( "\n\n# %s\n#\n# %s\n#\n", pht('Enter a commit message.'), pht('Changes:')); $paths = array_merge($uncommitted, $unstaged); $paths = array_unique($paths); sort($paths); foreach ($paths as $path) { $template .= "# ".$path."\n"; } $commit_message = $this->newInteractiveEditor($template) ->setName(pht('commit-message')) ->setTaskMessage(pht( 'Supply commit message for uncommitted changes, then save and '. 'exit.')) ->editInteractively(); if ($commit_message === $template) { throw new ArcanistUsageException( pht('You must provide a commit message.')); } $commit_message = ArcanistCommentRemover::removeComments( $commit_message); if (!strlen($commit_message)) { throw new ArcanistUsageException( pht('You must provide a nonempty commit message.')); } $api->doCommit($commit_message); } } } private function getShouldAmend() { if ($this->shouldAmend === null) { $this->shouldAmend = $this->calculateShouldAmend(); } return $this->shouldAmend; } private function calculateShouldAmend() { $api = $this->getRepositoryAPI(); if ($this->isHistoryImmutable() || !$api->supportsAmend()) { return false; } $commits = $api->getLocalCommitInformation(); if (!$commits) { return false; } $commit = reset($commits); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $commit['message']); if ($message->getGitSVNBaseRevision()) { return false; } if ($api->getAuthor() != $commit['author']) { return false; } if ($message->getRevisionID() && $this->getArgument('create')) { return false; } // TODO: Check commits since tracking branch. If empty then return false. // Don't amend the current commit if it has already been published. $repository = $this->loadProjectRepository(); if ($repository) { $repo_id = $repository['id']; $commit_hash = $commit['commit']; $callsign = idx($repository, 'callsign'); if ($callsign) { // The server might be too old to support the new style commit names, // so prefer the old way $commit_name = "r{$callsign}{$commit_hash}"; } else { $commit_name = "R{$repo_id}:{$commit_hash}"; } $result = $this->getConduit()->callMethodSynchronous( 'diffusion.querycommits', array('names' => array($commit_name))); $known_commit = idx($result['identifierMap'], $commit_name); if ($known_commit) { return false; } } if (!$message->getRevisionID()) { return true; } $in_working_copy = $api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if ($in_working_copy) { return true; } return false; } private function askForAdd(array $files) { if ($this->commitMode == self::COMMIT_DISABLE) { return false; } if ($this->commitMode == self::COMMIT_ENABLE) { return true; } $prompt = $this->getAskForAddPrompt($files); return phutil_console_confirm($prompt); } private function getAskForAddPrompt(array $files) { if ($this->getShouldAmend()) { $prompt = pht( 'Do you want to amend these %s change(s) to the current commit?', phutil_count($files)); } else { $prompt = pht( 'Do you want to create a new commit with these %s change(s)?', phutil_count($files)); } return $prompt; } final protected function loadDiffBundleFromConduit( ConduitClient $conduit, $diff_id) { return $this->loadBundleFromConduit( $conduit, array( 'ids' => array($diff_id), )); } final protected function loadRevisionBundleFromConduit( ConduitClient $conduit, $revision_id) { return $this->loadBundleFromConduit( $conduit, array( 'revisionIDs' => array($revision_id), )); } private function loadBundleFromConduit( ConduitClient $conduit, $params) { $future = $conduit->callMethod('differential.querydiffs', $params); $diff = head($future->resolve()); if ($diff == null) { throw new Exception( phutil_console_wrap( pht("The diff or revision you specified is either invalid or you ". "don't have permission to view it.")) ); } $changes = array(); foreach ($diff['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setConduit($conduit); // since the conduit method has changes, assume that these fields // could be unset $bundle->setBaseRevision(idx($diff, 'sourceControlBaseRevision')); $bundle->setRevisionID(idx($diff, 'revisionID')); $bundle->setAuthorName(idx($diff, 'authorName')); $bundle->setAuthorEmail(idx($diff, 'authorEmail')); return $bundle; } /** * Return a list of lines changed by the current diff, or ##null## if the * change list is meaningless (for example, because the path is a directory * or binary file). * * @param string Path within the repository. * @param string Change selection mode (see ArcanistDiffHunk). * @return list|null List of changed line numbers, or null to indicate that * the path is not a line-oriented text file. */ final protected function getChangedLines($path, $mode) { $repository_api = $this->getRepositoryAPI(); $full_path = $repository_api->getPath($path); if (is_dir($full_path)) { return null; } if (!file_exists($full_path)) { return null; } $change = $this->getChange($path); if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) { return null; } $lines = $change->getChangedLines($mode); return array_keys($lines); } final protected function getChange($path) { $repository_api = $this->getRepositoryAPI(); // TODO: Very gross $is_git = ($repository_api instanceof ArcanistGitAPI); $is_hg = ($repository_api instanceof ArcanistMercurialAPI); $is_svn = ($repository_api instanceof ArcanistSubversionAPI); if ($is_svn) { // NOTE: In SVN, we don't currently support a "get all local changes" // operation, so special case it. if (empty($this->changeCache[$path])) { $diff = $repository_api->getRawDiffText($path); $parser = $this->newDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception(pht('Expected exactly one change.')); } $this->changeCache[$path] = reset($changes); } } else if ($is_git || $is_hg) { if (empty($this->changeCache)) { $changes = $repository_api->getAllLocalChanges(); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } else { throw new Exception(pht('Missing VCS support.')); } if (empty($this->changeCache[$path])) { if ($is_git || $is_hg) { // This can legitimately occur under git/hg if you make a change, // "git/hg commit" it, and then revert the change in the working copy // and run "arc lint". $change = new ArcanistDiffChange(); $change->setCurrentPath($path); return $change; } else { throw new Exception( pht( "Trying to get change for unchanged path '%s'!", $path)); } } return $this->changeCache[$path]; } final public function willRunWorkflow() { $spec = $this->getCompleteArgumentSpecification(); foreach ($this->arguments as $arg => $value) { if (empty($spec[$arg])) { continue; } $options = $spec[$arg]; if (!empty($options['supports'])) { $system_name = $this->getRepositoryAPI()->getSourceControlSystemName(); if (!in_array($system_name, $options['supports'])) { $extended_info = null; if (!empty($options['nosupport'][$system_name])) { $extended_info = ' '.$options['nosupport'][$system_name]; } throw new ArcanistUsageException( pht( "Option '%s' is not supported under %s.", "--{$arg}", $system_name). $extended_info); } } } } final protected function normalizeRevisionID($revision_id) { return preg_replace('/^D/i', '', $revision_id); } protected function shouldShellComplete() { return true; } protected function getShellCompletions(array $argv) { return array(); } public function getSupportedRevisionControlSystems() { return array('git', 'hg', 'svn'); } final protected function getPassthruArgumentsAsMap($command) { $map = array(); foreach ($this->getCompleteArgumentSpecification() as $key => $spec) { if (!empty($spec['passthru'][$command])) { if (isset($this->arguments[$key])) { $map[$key] = $this->arguments[$key]; } } } return $map; } final protected function getPassthruArgumentsAsArgv($command) { $spec = $this->getCompleteArgumentSpecification(); $map = $this->getPassthruArgumentsAsMap($command); $argv = array(); foreach ($map as $key => $value) { $argv[] = '--'.$key; if (!empty($spec[$key]['param'])) { $argv[] = $value; } } return $argv; } /** * Write a message to stderr so that '--json' flags or stdout which is meant * to be piped somewhere aren't disrupted. * * @param string Message to write to stderr. * @return void */ final protected function writeStatusMessage($msg) { fwrite(STDERR, $msg); } final public function writeInfo($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final public function writeWarn($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final public function writeOkay($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final protected function isHistoryImmutable() { $repository_api = $this->getRepositoryAPI(); $config = $this->getConfigFromAnySource('history.immutable'); if ($config !== null) { return $config; } return $repository_api->isHistoryDefaultImmutable(); } /** * Workflows like 'lint' and 'unit' operate on a list of working copy paths. * The user can either specify the paths explicitly ("a.js b.php"), or by * specifying a revision ("--rev a3f10f1f") to select all paths modified * since that revision, or by omitting both and letting arc choose the * default relative revision. * * This method takes the user's selections and returns the paths that the * workflow should act upon. * * @param list List of explicitly provided paths. * @param string|null Revision name, if provided. * @param mask Mask of ArcanistRepositoryAPI flags to exclude. * Defaults to ArcanistRepositoryAPI::FLAG_UNTRACKED. * @return list List of paths the workflow should act on. */ final protected function selectPathsForWorkflow( array $paths, $rev, $omit_mask = null) { if ($omit_mask === null) { $omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED; } if ($paths) { $working_copy = $this->getWorkingCopyIdentity(); foreach ($paths as $key => $path) { $full_path = Filesystem::resolvePath($path); if (!Filesystem::pathExists($full_path)) { throw new ArcanistUsageException( pht( "Path '%s' does not exist!", $path)); } $relative_path = Filesystem::readablePath( $full_path, $working_copy->getProjectRoot()); $paths[$key] = $relative_path; } } else { $repository_api = $this->getRepositoryAPI(); if ($rev) { $this->parseBaseCommitArgument(array($rev)); } $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $flags) { if ($flags & $omit_mask) { unset($paths[$path]); } } $paths = array_keys($paths); } return array_values($paths); } final protected function renderRevisionList(array $revisions) { $list = array(); foreach ($revisions as $revision) { $list[] = ' - D'.$revision['id'].': '.$revision['title']."\n"; } return implode('', $list); } /* -( Scratch Files )------------------------------------------------------ */ /** * Try to read a scratch file, if it exists and is readable. * * @param string Scratch file name. * @return mixed String for file contents, or false for failure. * @task scratch */ final protected function readScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->readScratchFile($path); } /** * Try to read a scratch JSON file, if it exists and is readable. * * @param string Scratch file name. * @return array Empty array for failure. * @task scratch */ final protected function readScratchJSONFile($path) { $file = $this->readScratchFile($path); if (!$file) { return array(); } return phutil_json_decode($file); } /** * Try to write a scratch file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param string Data to write. * @return bool True on success, false on failure. * @task scratch */ final protected function writeScratchFile($path, $data) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->writeScratchFile($path, $data); } /** * Try to write a scratch JSON file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param array Data to write. * @return bool True on success, false on failure. * @task scratch */ final protected function writeScratchJSONFile($path, array $data) { return $this->writeScratchFile($path, json_encode($data)); } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ final protected function removeScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->removeScratchFile($path); } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ final protected function getReadableScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getReadableScratchFilePath($path); } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ final protected function getScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getScratchFilePath($path); } final protected function getRepositoryEncoding() { return nonempty( idx($this->loadProjectRepository(), 'encoding'), 'UTF-8'); } final protected function loadProjectRepository() { list($info, $reasons) = $this->loadRepositoryInformation(); return coalesce($info, array()); } final protected function newInteractiveEditor($text) { $editor = new PhutilInteractiveEditor($text); $preferred = $this->getConfigFromAnySource('editor'); if ($preferred) { $editor->setPreferredEditor($preferred); } return $editor; } final protected function newDiffParser() { $parser = new ArcanistDiffParser(); if ($this->repositoryAPI) { $parser->setRepositoryAPI($this->getRepositoryAPI()); } $parser->setWriteDiffOnFailure(true); return $parser; } final protected function dispatchEvent($type, array $data) { $data += array( 'workflow' => $this, ); $event = new PhutilEvent($type, $data); PhutilEventEngine::dispatchEvent($event); return $event; } final public function parseBaseCommitArgument(array $argv) { if (!count($argv)) { return; } $api = $this->getRepositoryAPI(); if (!$api->supportsCommitRanges()) { throw new ArcanistUsageException( pht('This version control system does not support commit ranges.')); } if (count($argv) > 1) { throw new ArcanistUsageException( pht( 'Specify exactly one base commit. The end of the commit range is '. 'always the working copy state.')); } $api->setBaseCommit(head($argv)); return $this; } final protected function getRepositoryVersion() { if (!$this->repositoryVersion) { $api = $this->getRepositoryAPI(); $commit = $api->getSourceControlBaseRevision(); $versions = array('' => $commit); foreach ($api->getChangedFiles($commit) as $path => $mask) { $versions[$path] = (Filesystem::pathExists($path) ? md5_file($path) : ''); } $this->repositoryVersion = md5(json_encode($versions)); } return $this->repositoryVersion; } /* -( Phabricator Repositories )------------------------------------------- */ /** * Get the PHID of the Phabricator repository this working copy corresponds * to. Returns `null` if no repository can be identified. * * @return phid|null Repository PHID, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryPHID() { return idx($this->getRepositoryInformation(), 'phid'); } /** * Get the name of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository name, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryName() { return idx($this->getRepositoryInformation(), 'name'); } /** * Get the URI of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository URI, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryURI() { return idx($this->getRepositoryInformation(), 'uri'); } final protected function getRepositoryStagingConfiguration() { return idx($this->getRepositoryInformation(), 'staging'); } /** * Get human-readable reasoning explaining how `arc` evaluated which * Phabricator repository corresponds to this working copy. Used by * `arc which` to explain the process to users. * * @return list Human-readable explanation of the repository * association process. * * @task phabrep */ final protected function getRepositoryReasons() { $this->getRepositoryInformation(); return $this->repositoryReasons; } /** * @task phabrep */ private function getRepositoryInformation() { if ($this->repositoryInfo === null) { list($info, $reasons) = $this->loadRepositoryInformation(); $this->repositoryInfo = nonempty($info, array()); $this->repositoryReasons = $reasons; } return $this->repositoryInfo; } /** * @task phabrep */ private function loadRepositoryInformation() { list($query, $reasons) = $this->getRepositoryQuery(); if (!$query) { return array(null, $reasons); } try { $method = 'repository.query'; $results = $this->getConduitEngine() ->newFuture($method, $query) ->resolve(); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { $reasons[] = pht( 'This version of Arcanist is more recent than the version of '. 'Phabricator you are connecting to: the Phabricator install is '. 'out of date and does not have support for identifying '. 'repositories by callsign or URI. Update Phabricator to enable '. 'these features.'); return array(null, $reasons); } throw $ex; } $result = null; if (!$results) { $reasons[] = pht( 'No repositories matched the query. Check that your configuration '. 'is correct, or use "%s" to select a repository explicitly.', 'repository.callsign'); } else if (count($results) > 1) { $reasons[] = pht( 'Multiple repostories (%s) matched the query. You can use the '. '"%s" configuration to select the one you want.', implode(', ', ipull($results, 'callsign')), 'repository.callsign'); } else { $result = head($results); $reasons[] = pht('Found a unique matching repository.'); } return array($result, $reasons); } /** * @task phabrep */ private function getRepositoryQuery() { $reasons = array(); $callsign = $this->getConfigFromAnySource('repository.callsign'); if ($callsign) { $query = array( 'callsigns' => array($callsign), ); $reasons[] = pht( 'Configuration value "%s" is set to "%s".', 'repository.callsign', $callsign); return array($query, $reasons); } else { $reasons[] = pht( 'Configuration value "%s" is empty.', 'repository.callsign'); } $uuid = $this->getRepositoryAPI()->getRepositoryUUID(); if ($uuid !== null) { $query = array( 'uuids' => array($uuid), ); $reasons[] = pht( 'The UUID for this working copy is "%s".', $uuid); return array($query, $reasons); } else { $reasons[] = pht( 'This repository has no VCS UUID (this is normal for git/hg).'); } // TODO: Swap this for a RemoteRefQuery. $remote_uri = $this->getRepositoryAPI()->getRemoteURI(); if ($remote_uri !== null) { $query = array( 'remoteURIs' => array($remote_uri), ); $reasons[] = pht( 'The remote URI for this working copy is "%s".', $remote_uri); return array($query, $reasons); } else { $reasons[] = pht( 'Unable to determine the remote URI for this repository.'); } return array(null, $reasons); } /** * Build a new lint engine for the current working copy. * * Optionally, you can pass an explicit engine class name to build an engine * of a particular class. Normally this is used to implement an `--engine` * flag from the CLI. * * @param string Optional explicit engine class name. * @return ArcanistLintEngine Constructed engine. */ protected function newLintEngine($engine_class = null) { $working_copy = $this->getWorkingCopyIdentity(); $config = $this->getConfigurationManager(); if (!$engine_class) { $engine_class = $config->getConfigFromAnySource('lint.engine'); } if (!$engine_class) { if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) { $engine_class = 'ArcanistConfigurationDrivenLintEngine'; } } if (!$engine_class) { throw new ArcanistNoEngineException( pht( "No lint engine is configured for this project. Create an '%s' ". "file, or configure an advanced engine with '%s' in '%s'.", '.arclint', 'lint.engine', '.arcconfig')); } $base_class = 'ArcanistLintEngine'; if (!class_exists($engine_class) || !is_subclass_of($engine_class, $base_class)) { throw new ArcanistUsageException( pht( 'Configured lint engine "%s" is not a subclass of "%s", but must be.', $engine_class, $base_class)); } $engine = newv($engine_class, array()) ->setWorkingCopy($working_copy) ->setConfigurationManager($config); return $engine; } /** * Build a new unit test engine for the current working copy. * * Optionally, you can pass an explicit engine class name to build an engine * of a particular class. Normally this is used to implement an `--engine` * flag from the CLI. * * @param string Optional explicit engine class name. * @return ArcanistUnitTestEngine Constructed engine. */ protected function newUnitTestEngine($engine_class = null) { $working_copy = $this->getWorkingCopyIdentity(); $config = $this->getConfigurationManager(); if (!$engine_class) { $engine_class = $config->getConfigFromAnySource('unit.engine'); } if (!$engine_class) { if (Filesystem::pathExists($working_copy->getProjectPath('.arcunit'))) { $engine_class = 'ArcanistConfigurationDrivenUnitTestEngine'; } } if (!$engine_class) { throw new ArcanistNoEngineException( pht( "No unit test engine is configured for this project. Create an ". "'%s' file, or configure an advanced engine with '%s' in '%s'.", '.arcunit', 'unit.engine', '.arcconfig')); } $base_class = 'ArcanistUnitTestEngine'; if (!class_exists($engine_class) || !is_subclass_of($engine_class, $base_class)) { throw new ArcanistUsageException( pht( 'Configured unit test engine "%s" is not a subclass of "%s", '. 'but must be.', $engine_class, $base_class)); } $engine = newv($engine_class, array()) ->setWorkingCopy($working_copy) ->setConfigurationManager($config); return $engine; } protected function openURIsInBrowser(array $uris) { $browser = $this->getBrowserCommand(); // The "browser" may actually be a list of arguments. if (!is_array($browser)) { $browser = array($browser); } foreach ($uris as $uri) { $err = phutil_passthru('%LR %R', $browser, $uri); if ($err) { throw new ArcanistUsageException( pht( 'Failed to open URI "%s" in browser ("%s"). '. 'Check your "browser" config option.', $uri, implode(' ', $browser))); } } } private function getBrowserCommand() { $config = $this->getConfigFromAnySource('browser'); if ($config) { return $config; } if (phutil_is_windows()) { // See T13504. We now use "bypass_shell", so "start" alone is no longer // a valid binary to invoke directly. return array( 'cmd', '/c', 'start', ); } $candidates = array( 'sensible-browser' => array('sensible-browser'), 'xdg-open' => array('xdg-open'), 'open' => array('open', '--'), ); // NOTE: The "open" command works well on OS X, but on many Linuxes "open" // exists and is not a browser. For now, we're just looking for other // commands first, but we might want to be smarter about selecting "open" // only on OS X. foreach ($candidates as $cmd => $argv) { if (Filesystem::binaryExists($cmd)) { return $argv; } } throw new ArcanistUsageException( pht( "Unable to find a browser command to run. Set '%s' in your ". "Arcanist config to specify a command to use.", 'browser')); } /** * Ask Phabricator to update the current repository as soon as possible. * * Calling this method after pushing commits allows Phabricator to discover * the commits more quickly, so the system overall is more responsive. * * @return void */ protected function askForRepositoryUpdate() { // If we know which repository we're in, try to tell Phabricator that we // pushed commits to it so it can update. This hint can help pull updates // more quickly, especially in rarely-used repositories. if ($this->getRepositoryPHID()) { try { $this->getConduit()->callMethodSynchronous( 'diffusion.looksoon', array( 'repositories' => array($this->getRepositoryPHID()), )); } catch (ConduitClientException $ex) { // If we hit an exception, just ignore it. Likely, we are running // against a Phabricator which is too old to support this method. // Since this hint is purely advisory, it doesn't matter if it has // no effect. } } } protected function getModernLintDictionary(array $map) { $map = $this->getModernCommonDictionary($map); return $map; } protected function getModernUnitDictionary(array $map) { $map = $this->getModernCommonDictionary($map); $details = idx($map, 'userData'); if (strlen($details)) { $map['details'] = (string)$details; } unset($map['userData']); return $map; } private function getModernCommonDictionary(array $map) { foreach ($map as $key => $value) { if ($value === null) { unset($map[$key]); } } return $map; } final public function setConduitEngine( ArcanistConduitEngine $conduit_engine) { $this->conduitEngine = $conduit_engine; return $this; } final public function getConduitEngine() { return $this->conduitEngine; } final public function getRepositoryRef() { $configuration_engine = $this->getConfigurationEngine(); if ($configuration_engine) { // This is a toolset workflow and can always build a repository ref. } else { if (!$this->getConfigurationManager()->getWorkingCopyIdentity()) { return null; } if (!$this->repositoryAPI) { return null; } } if (!$this->repositoryRef) { $ref = id(new ArcanistRepositoryRef()) ->setPHID($this->getRepositoryPHID()) ->setBrowseURI($this->getRepositoryURI()); $this->repositoryRef = $ref; } return $this->repositoryRef; } final public function getToolsetKey() { return $this->getToolset()->getToolsetKey(); } final public function getConfig($key) { return $this->getConfigurationSourceList()->getConfig($key); } public function canHandleSignal($signo) { return false; } public function handleSignal($signo) { return; } final public function newCommand(PhutilExecutableFuture $future) { return id(new ArcanistCommand()) ->setLogEngine($this->getLogEngine()) ->setExecutableFuture($future); } final public function loadHardpoints( $objects, $requests) { return $this->getRuntime()->loadHardpoints($objects, $requests); } protected function newPrompts() { return array(); } protected function newPrompt($key) { return id(new ArcanistPrompt()) ->setWorkflow($this) ->setKey($key); } public function hasPrompt($key) { $map = $this->getPromptMap(); return isset($map[$key]); } public function getPromptMap() { if ($this->promptMap === null) { $prompts = $this->newPrompts(); assert_instances_of($prompts, 'ArcanistPrompt'); // TODO: Move this somewhere modular. $prompts[] = $this->newPrompt('arc.state.stash') ->setDescription( pht( 'Prompts the user to stash changes and continue when the '. 'working copy has untracked, uncommitted, or unstaged '. 'changes.')); // TODO: Swap to ArrayCheck? $map = array(); foreach ($prompts as $prompt) { $key = $prompt->getKey(); if (isset($map[$key])) { throw new Exception( pht( 'Workflow ("%s") generates two prompts with the same '. 'key ("%s"). Each prompt a workflow generates must have a '. 'unique key.', get_class($this), $key)); } $map[$key] = $prompt; } $this->promptMap = $map; } return $this->promptMap; } final public function getPrompt($key) { $map = $this->getPromptMap(); $prompt = idx($map, $key); if (!$prompt) { throw new Exception( pht( 'Workflow ("%s") is requesting a prompt ("%s") but it did not '. 'generate any prompt with that name in "newPrompts()".', get_class($this), $key)); } return clone $prompt; } final protected function getSymbolEngine() { return $this->getRuntime()->getSymbolEngine(); } final protected function getViewer() { return $this->getRuntime()->getViewer(); } final protected function readStdin() { $log = $this->getLogEngine(); $log->writeWaitingForInput(); // NOTE: We can't just "file_get_contents()" here because signals don't // interrupt it. If the user types "^C", we want to interrupt the read. $raw_handle = fopen('php://stdin', 'rb'); $stdin = new PhutilSocketChannel($raw_handle); while ($stdin->update()) { PhutilChannel::waitForAny(array($stdin)); } return $stdin->read(); } final public function getAbsoluteURI($raw_uri) { // TODO: "ArcanistRevisionRef", at least, may return a relative URI. // If we get a relative URI, guess the correct absolute URI based on // the Conduit URI. This might not be correct for Conduit over SSH. $raw_uri = new PhutilURI($raw_uri); if (!strlen($raw_uri->getDomain())) { $base_uri = $this->getConduitEngine() ->getConduitURI(); $raw_uri = id(new PhutilURI($base_uri)) ->setPath($raw_uri->getPath()); } $raw_uri = phutil_string_cast($raw_uri); return $raw_uri; } final public function writeToPager($corpus) { $is_tty = (function_exists('posix_isatty') && posix_isatty(STDOUT)); if (!$is_tty) { echo $corpus; } else { $pager = $this->getConfig('pager'); if (!$pager) { $pager = array('less', '-R', '--'); } // Try to show the content through a pager. $err = id(new PhutilExecPassthru('%Ls', $pager)) ->write($corpus) ->resolve(); // If the pager exits with an error, print the content normally. if ($err) { echo $corpus; } } return $this; } }