MaxSem has uploaded a new change for review. https://gerrit.wikimedia.org/r/319505
Change subject: WIP: replace wfShellExec() with a class ...................................................................... WIP: replace wfShellExec() with a class This function has gotten so unwieldy that a helper was introduced. Instead, I'm proposing this class that makes shelling out easier and more readable. Example usage: $command = Shell::command( 'shell command' ) ->environment( [ 'ENVIRONMENT_VARIABLE' => 'VALUE' ] ) ->limits( [ 'time' => 300 ] ) ->execute(); $exitCode = $command->getExitCode(); $output = $command->getOutput(); This is a minimal change, so lots of stuff remains unrefactored - I'd rather limit the scope of this commit. A future improvement could be an ability to get stderr separately from stdout. Problem points that I'd like to discuss: * I went with a purely OOP approach for handling the 'proc_open() is disabled' error - throw an exception and forget. While stubbed wfShellExec() retains the old behavior, once callers are updated they will either need to catch the exception or they will break for some poor souls - another option would be to introduce a noThrow() option. * proc_open() failures currently just return a status code - do something about that, too? Change-Id: I8ac9858b80d7908cf7e7981d7e19d0fc9c2265c0 --- M autoload.php M includes/GlobalFunctions.php A includes/exception/ShellDisabledError.php A includes/shell/Shell.php 4 files changed, 444 insertions(+), 290 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core refs/changes/05/319505/1 diff --git a/autoload.php b/autoload.php index b96250d..27952b3 100644 --- a/autoload.php +++ b/autoload.php @@ -904,6 +904,8 @@ 'MediaWiki\\Session\\SessionProviderInterface' => __DIR__ . '/includes/session/SessionProviderInterface.php', 'MediaWiki\\Session\\Token' => __DIR__ . '/includes/session/Token.php', 'MediaWiki\\Session\\UserInfo' => __DIR__ . '/includes/session/UserInfo.php', + 'MediaWiki\\Shell' => __DIR__ . '/includes/shell/Shell.php', + 'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php', 'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php', 'MediaWiki\\Tidy\\BalanceActiveFormattingElements' => __DIR__ . '/includes/tidy/Balancer.php', 'MediaWiki\\Tidy\\BalanceElement' => __DIR__ . '/includes/tidy/Balancer.php', diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index bae9c77..e6d3f24 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -27,6 +27,7 @@ use Liuggio\StatsdClient\Sender\SocketSender; use MediaWiki\Logger\LoggerFactory; use MediaWiki\Session\SessionManager; +use MediaWiki\Shell; use Wikimedia\ScopedCallback; // Hide compatibility functions from Doxygen @@ -2205,66 +2206,11 @@ * * @param string ... strings to escape and glue together, or a single array of strings parameter * @return string + * @deprecated since 1.29 use MediaWiki\Shell::escape() */ function wfEscapeShellArg( /*...*/ ) { - wfInitShellLocale(); - $args = func_get_args(); - if ( count( $args ) === 1 && is_array( reset( $args ) ) ) { - // If only one argument has been passed, and that argument is an array, - // treat it as a list of arguments - $args = reset( $args ); - } - - $first = true; - $retVal = ''; - foreach ( $args as $arg ) { - if ( !$first ) { - $retVal .= ' '; - } else { - $first = false; - } - - if ( wfIsWindows() ) { - // Escaping for an MSVC-style command line parser and CMD.EXE - // @codingStandardsIgnoreStart For long URLs - // Refs: - // * http://web.archive.org/web/20020708081031/http://mailman.lyra.org/pipermail/scite-interest/2002-March/000436.html - // * http://technet.microsoft.com/en-us/library/cc723564.aspx - // * T15518 - // * CR r63214 - // Double the backslashes before any double quotes. Escape the double quotes. - // @codingStandardsIgnoreEnd - $tokens = preg_split( '/(\\\\*")/', $arg, -1, PREG_SPLIT_DELIM_CAPTURE ); - $arg = ''; - $iteration = 0; - foreach ( $tokens as $token ) { - if ( $iteration % 2 == 1 ) { - // Delimiter, a double quote preceded by zero or more slashes - $arg .= str_replace( '\\', '\\\\', substr( $token, 0, -1 ) ) . '\\"'; - } elseif ( $iteration % 4 == 2 ) { - // ^ in $token will be outside quotes, need to be escaped - $arg .= str_replace( '^', '^^', $token ); - } else { // $iteration % 4 == 0 - // ^ in $token will appear inside double quotes, so leave as is - $arg .= $token; - } - $iteration++; - } - // Double the backslashes before the end of the string, because - // we will soon add a quote - $m = []; - if ( preg_match( '/^(.*?)(\\\\+)$/', $arg, $m ) ) { - $arg = $m[1] . str_replace( '\\', '\\\\', $m[2] ); - } - - // Add surrounding quotes - $retVal .= '"' . $arg . '"'; - } else { - $retVal .= escapeshellarg( $arg ); - } - } - return $retVal; + return call_user_func( 'MediaWiki\\Shell::escape', $args ); } /** @@ -2272,18 +2218,10 @@ * * @return bool|string False or 'disabled' * @since 1.22 + * @deprecated since 1.29 use MediaWiki\Shell::isDisabled() */ function wfShellExecDisabled() { - static $disabled = null; - if ( is_null( $disabled ) ) { - if ( !function_exists( 'proc_open' ) ) { - wfDebug( "proc_open() is disabled\n" ); - $disabled = 'disabled'; - } else { - $disabled = false; - } - } - return $disabled; + return Shell::isDisabled() ? 'disabled' : false; } /** @@ -2307,15 +2245,12 @@ * method. Set this to a string for an alternative method to profile from * * @return string Collected stdout as a string + * @deprecated since 1.29 use class MediaWiki\Shell */ function wfShellExec( $cmd, &$retval = null, $environ = [], $limits = [], $options = [] ) { - global $IP, $wgMaxShellMemory, $wgMaxShellFileSize, $wgMaxShellTime, - $wgMaxShellWallClockTime, $wgShellCgroup; - - $disabled = wfShellExecDisabled(); - if ( $disabled ) { + if ( Shell::isDisabled() ) { $retval = 1; return 'Unable to run external programs, proc_open() is disabled.'; } @@ -2323,207 +2258,16 @@ $includeStderr = isset( $options['duplicateStderr'] ) && $options['duplicateStderr']; $profileMethod = isset( $options['profileMethod'] ) ? $options['profileMethod'] : wfGetCaller(); - wfInitShellLocale(); + $command = Shell::command( $cmd ) + ->environment( $environ ) + ->limits( $limits ) + ->includeStderr( $includeStderr ) + ->profileMethod( $profileMethod ) + ->execute(); - $envcmd = ''; - foreach ( $environ as $k => $v ) { - if ( wfIsWindows() ) { - /* Surrounding a set in quotes (method used by wfEscapeShellArg) makes the quotes themselves - * appear in the environment variable, so we must use carat escaping as documented in - * http://technet.microsoft.com/en-us/library/cc723564.aspx - * Note however that the quote isn't listed there, but is needed, and the parentheses - * are listed there but doesn't appear to need it. - */ - $envcmd .= "set $k=" . preg_replace( '/([&|()<>^"])/', '^\\1', $v ) . '&& '; - } else { - /* Assume this is a POSIX shell, thus required to accept variable assignments before the command - * http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_09_01 - */ - $envcmd .= "$k=" . escapeshellarg( $v ) . ' '; - } - } - if ( is_array( $cmd ) ) { - $cmd = wfEscapeShellArg( $cmd ); - } + $retval = $command->getExitCode(); - $cmd = $envcmd . $cmd; - - $useLogPipe = false; - if ( is_executable( '/bin/bash' ) ) { - $time = intval( isset( $limits['time'] ) ? $limits['time'] : $wgMaxShellTime ); - if ( isset( $limits['walltime'] ) ) { - $wallTime = intval( $limits['walltime'] ); - } elseif ( isset( $limits['time'] ) ) { - $wallTime = $time; - } else { - $wallTime = intval( $wgMaxShellWallClockTime ); - } - $mem = intval( isset( $limits['memory'] ) ? $limits['memory'] : $wgMaxShellMemory ); - $filesize = intval( isset( $limits['filesize'] ) ? $limits['filesize'] : $wgMaxShellFileSize ); - - if ( $time > 0 || $mem > 0 || $filesize > 0 || $wallTime > 0 ) { - $cmd = '/bin/bash ' . escapeshellarg( "$IP/includes/limit.sh" ) . ' ' . - escapeshellarg( $cmd ) . ' ' . - escapeshellarg( - "MW_INCLUDE_STDERR=" . ( $includeStderr ? '1' : '' ) . ';' . - "MW_CPU_LIMIT=$time; " . - 'MW_CGROUP=' . escapeshellarg( $wgShellCgroup ) . '; ' . - "MW_MEM_LIMIT=$mem; " . - "MW_FILE_SIZE_LIMIT=$filesize; " . - "MW_WALL_CLOCK_LIMIT=$wallTime; " . - "MW_USE_LOG_PIPE=yes" - ); - $useLogPipe = true; - } elseif ( $includeStderr ) { - $cmd .= ' 2>&1'; - } - } elseif ( $includeStderr ) { - $cmd .= ' 2>&1'; - } - wfDebug( "wfShellExec: $cmd\n" ); - - // Don't try to execute commands that exceed Linux's MAX_ARG_STRLEN. - // Other platforms may be more accomodating, but we don't want to be - // accomodating, because very long commands probably include user - // input. See T129506. - if ( strlen( $cmd ) > SHELL_MAX_ARG_STRLEN ) { - throw new Exception( __METHOD__ . - '(): total length of $cmd must not exceed SHELL_MAX_ARG_STRLEN' ); - } - - $desc = [ - 0 => [ 'file', 'php://stdin', 'r' ], - 1 => [ 'pipe', 'w' ], - 2 => [ 'file', 'php://stderr', 'w' ] ]; - if ( $useLogPipe ) { - $desc[3] = [ 'pipe', 'w' ]; - } - $pipes = null; - $scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod ); - $proc = proc_open( $cmd, $desc, $pipes ); - if ( !$proc ) { - wfDebugLog( 'exec', "proc_open() failed: $cmd" ); - $retval = -1; - return ''; - } - $outBuffer = $logBuffer = ''; - $emptyArray = []; - $status = false; - $logMsg = false; - - /* According to the documentation, it is possible for stream_select() - * to fail due to EINTR. I haven't managed to induce this in testing - * despite sending various signals. If it did happen, the error - * message would take the form: - * - * stream_select(): unable to select [4]: Interrupted system call (max_fd=5) - * - * where [4] is the value of the macro EINTR and "Interrupted system - * call" is string which according to the Linux manual is "possibly" - * localised according to LC_MESSAGES. - */ - $eintr = defined( 'SOCKET_EINTR' ) ? SOCKET_EINTR : 4; - $eintrMessage = "stream_select(): unable to select [$eintr]"; - - $running = true; - $timeout = null; - $numReadyPipes = 0; - - while ( $running === true || $numReadyPipes !== 0 ) { - if ( $running ) { - $status = proc_get_status( $proc ); - // If the process has terminated, switch to nonblocking selects - // for getting any data still waiting to be read. - if ( !$status['running'] ) { - $running = false; - $timeout = 0; - } - } - - $readyPipes = $pipes; - - // Clear last error - // @codingStandardsIgnoreStart Generic.PHP.NoSilencedErrors.Discouraged - @trigger_error( '' ); - $numReadyPipes = @stream_select( $readyPipes, $emptyArray, $emptyArray, $timeout ); - if ( $numReadyPipes === false ) { - // @codingStandardsIgnoreEnd - $error = error_get_last(); - if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) { - continue; - } else { - trigger_error( $error['message'], E_USER_WARNING ); - $logMsg = $error['message']; - break; - } - } - foreach ( $readyPipes as $fd => $pipe ) { - $block = fread( $pipe, 65536 ); - if ( $block === '' ) { - // End of file - fclose( $pipes[$fd] ); - unset( $pipes[$fd] ); - if ( !$pipes ) { - break 2; - } - } elseif ( $block === false ) { - // Read error - $logMsg = "Error reading from pipe"; - break 2; - } elseif ( $fd == 1 ) { - // From stdout - $outBuffer .= $block; - } elseif ( $fd == 3 ) { - // From log FD - $logBuffer .= $block; - if ( strpos( $block, "\n" ) !== false ) { - $lines = explode( "\n", $logBuffer ); - $logBuffer = array_pop( $lines ); - foreach ( $lines as $line ) { - wfDebugLog( 'exec', $line ); - } - } - } - } - } - - foreach ( $pipes as $pipe ) { - fclose( $pipe ); - } - - // Use the status previously collected if possible, since proc_get_status() - // just calls waitpid() which will not return anything useful the second time. - if ( $running ) { - $status = proc_get_status( $proc ); - } - - if ( $logMsg !== false ) { - // Read/select error - $retval = -1; - proc_close( $proc ); - } elseif ( $status['signaled'] ) { - $logMsg = "Exited with signal {$status['termsig']}"; - $retval = 128 + $status['termsig']; - proc_close( $proc ); - } else { - if ( $status['running'] ) { - $retval = proc_close( $proc ); - } else { - $retval = $status['exitcode']; - proc_close( $proc ); - } - if ( $retval == 127 ) { - $logMsg = "Possibly missing executable file"; - } elseif ( $retval >= 129 && $retval <= 192 ) { - $logMsg = "Probably exited with signal " . ( $retval - 128 ); - } - } - - if ( $logMsg !== false ) { - wfDebugLog( 'exec', "$logMsg: $cmd" ); - } - - return $outBuffer; + return $command->getOutput(); } /** @@ -2541,25 +2285,11 @@ * @param array $limits Optional array with limits(filesize, memory, time, walltime) * this overwrites the global wgMaxShell* limits. * @return string Collected stdout and stderr as a string + * @deprecated since 1.29 use class MediaWiki\Shell */ function wfShellExecWithStderr( $cmd, &$retval = null, $environ = [], $limits = [] ) { return wfShellExec( $cmd, $retval, $environ, $limits, [ 'duplicateStderr' => true, 'profileMethod' => wfGetCaller() ] ); -} - -/** - * Workaround for http://bugs.php.net/bug.php?id=45132 - * escapeshellarg() destroys non-ASCII characters if LANG is not a UTF-8 locale - */ -function wfInitShellLocale() { - static $done = false; - if ( $done ) { - return; - } - $done = true; - global $wgShellLocale; - putenv( "LC_CTYPE=$wgShellLocale" ); - setlocale( LC_CTYPE, $wgShellLocale ); } /** @@ -2585,7 +2315,7 @@ } $cmd[] = $script; // Escape each parameter for shell - return wfEscapeShellArg( array_merge( $cmd, $parameters ) ); + return Shell::escape( array_merge( $cmd, $parameters ) ); } /** @@ -2630,7 +2360,7 @@ fclose( $yourtextFile ); # Check for a conflict - $cmd = wfEscapeShellArg( $wgDiff3, '-a', '--overlap-only', $mytextName, + $cmd = Shell::escape( $wgDiff3, '-a', '--overlap-only', $mytextName, $oldtextName, $yourtextName ); $handle = popen( $cmd, 'r' ); @@ -2642,7 +2372,7 @@ pclose( $handle ); # Merge differences - $cmd = wfEscapeShellArg( $wgDiff3, '-a', '-e', '--merge', $mytextName, + $cmd = Shell::escape( $wgDiff3, '-a', '-e', '--merge', $mytextName, $oldtextName, $yourtextName ); $handle = popen( $cmd, 'r' ); $result = ''; @@ -2706,7 +2436,7 @@ fclose( $newtextFile ); // Get the diff of the two files - $cmd = "$wgDiff " . $params . ' ' . wfEscapeShellArg( $oldtextName, $newtextName ); + $cmd = "$wgDiff " . $params . ' ' . Shell::escape( $oldtextName, $newtextName ); $h = popen( $cmd, 'r' ); if ( !$h ) { diff --git a/includes/exception/ShellDisabledError.php b/includes/exception/ShellDisabledError.php new file mode 100644 index 0000000..6ad61c4 --- /dev/null +++ b/includes/exception/ShellDisabledError.php @@ -0,0 +1,11 @@ +<?php + +namespace MediaWiki; + +use Exception; + +class ShellDisabledError extends Exception { + public function __construct() { + parent::__construct( 'Unable to run external programs, proc_open() is disabled' ); + } +} diff --git a/includes/shell/Shell.php b/includes/shell/Shell.php new file mode 100644 index 0000000..082f1b5 --- /dev/null +++ b/includes/shell/Shell.php @@ -0,0 +1,411 @@ +<?php +/** + * Class used for executing shell commands + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +namespace MediaWiki; + +use Exception; +use Profiler; + +/** + * Executes shell commands. + * + * Use call chaining with this class for expressiveness: + * Shell::command( 'shell command' ) + * ->environment( [ 'ENVIRONMENT_VARIABLE' => 'VALUE' ] ) + * ->limits( [ 'time' => 300 ] ) + * ->execute() + */ +class Shell { + /** @var string|string[] */ + private $command; + + /** @var array */ + private $limits = []; + + /** @var string[] */ + private $env = []; + + /** @var string */ + private $method; + + /** @var bool */ + private $useStderr = false; + + private $output; + + private $exitCode; + + private function __construct( $command ) { + if ( is_array( $command ) ) { + $command = self::escape( $command ); + } + + $this->command = $command; + } + + public static function command( $command ) { + return new self( $command ); + } + + /** + * Check if this class is effectively disabled via php.ini config + * + * @return bool + */ + public static function isDisabled() { + static $disabled = null; + + if ( is_null( $disabled ) ) { + if ( !function_exists( 'proc_open' ) ) { + wfDebug( "proc_open() is disabled\n" ); + $disabled = true; + } else { + $disabled = false; + } + } + + return $disabled; + } + + public function limits( array $limits ) { + $this->limits = $limits; + + return $this; + } + + public function environment( array $env ) { + $this->env = $env; + + return $this; + } + + public function profileMethod( $method ) { + $this->method = $method; + + return $this; + } + + public function includeStderr( $yesno ) { + $this->useStderr = $yesno; + + return $this; + } + + public function execute() { + global $IP, $wgMaxShellMemory, $wgMaxShellFileSize, $wgMaxShellTime, + $wgMaxShellWallClockTime, $wgShellCgroup; + + if ( self::isDisabled() ) { + throw new ShellDisabledError(); + } + + $profileMethod = $this->method ?: wfGetCaller(); + + self::initShellLocale(); + + $envcmd = ''; + foreach ( $this->env as $k => $v ) { + if ( wfIsWindows() ) { + /* Surrounding a set in quotes (method used by wfEscapeShellArg) makes the quotes themselves + * appear in the environment variable, so we must use carat escaping as documented in + * http://technet.microsoft.com/en-us/library/cc723564.aspx + * Note however that the quote isn't listed there, but is needed, and the parentheses + * are listed there but doesn't appear to need it. + */ + $envcmd .= "set $k=" . preg_replace( '/([&|()<>^"])/', '^\\1', $v ) . '&& '; + } else { + /* Assume this is a POSIX shell, thus required to accept variable assignments before the command + * http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_09_01 + */ + $envcmd .= "$k=" . escapeshellarg( $v ) . ' '; + } + } + + $cmd = $envcmd . $this->command; + + $useLogPipe = false; + if ( is_executable( '/bin/bash' ) ) { + $time = intval( isset( $this->limits['time'] ) ? $this->limits['time'] : $wgMaxShellTime ); + if ( isset( $this->limits['walltime'] ) ) { + $wallTime = intval( $this->limits['walltime'] ); + } elseif ( isset( $this->limits['time'] ) ) { + $wallTime = $time; + } else { + $wallTime = intval( $wgMaxShellWallClockTime ); + } + $mem = intval( isset( $this->limits['memory'] ) ? $this->limits['memory'] : $wgMaxShellMemory ); + $filesize = intval( isset( $this->limits['filesize'] ) ? $this->limits['filesize'] : $wgMaxShellFileSize ); + + if ( $time > 0 || $mem > 0 || $filesize > 0 || $wallTime > 0 ) { + $cmd = '/bin/bash ' . escapeshellarg( "$IP/includes/limit.sh" ) . ' ' . + escapeshellarg( $cmd ) . ' ' . + escapeshellarg( + "MW_INCLUDE_STDERR=" . ( $this->useStderr ? '1' : '' ) . ';' . + "MW_CPU_LIMIT=$time; " . + 'MW_CGROUP=' . escapeshellarg( $wgShellCgroup ) . '; ' . + "MW_MEM_LIMIT=$mem; " . + "MW_FILE_SIZE_LIMIT=$filesize; " . + "MW_WALL_CLOCK_LIMIT=$wallTime; " . + "MW_USE_LOG_PIPE=yes" + ); + $useLogPipe = true; + } elseif ( $this->useStderr ) { + $cmd .= ' 2>&1'; + } + } elseif ( $this->useStderr ) { + $cmd .= ' 2>&1'; + } + wfDebug( __METHOD__ . ": $cmd\n" ); + + // Don't try to execute commands that exceed Linux's MAX_ARG_STRLEN. + // Other platforms may be more accomodating, but we don't want to be + // accomodating, because very long commands probably include user + // input. See T129506. + if ( strlen( $cmd ) > SHELL_MAX_ARG_STRLEN ) { + throw new Exception( __METHOD__ . + '(): total length of $cmd must not exceed SHELL_MAX_ARG_STRLEN' ); + } + + $desc = [ + 0 => [ 'file', 'php://stdin', 'r' ], + 1 => [ 'pipe', 'w' ], + 2 => [ 'file', 'php://stderr', 'w' ] ]; + if ( $useLogPipe ) { + $desc[3] = [ 'pipe', 'w' ]; + } + $pipes = null; + $scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod ); + $proc = proc_open( $cmd, $desc, $pipes ); + if ( !$proc ) { + // @todo: more structured error reporting? + wfDebugLog( 'exec', "proc_open() failed: $cmd" ); + $this->exitCode = -1; + $this->output = ''; + return $this; + } + $outBuffer = $logBuffer = ''; + $emptyArray = []; + $status = false; + $logMsg = false; + + /* According to the documentation, it is possible for stream_select() + * to fail due to EINTR. I haven't managed to induce this in testing + * despite sending various signals. If it did happen, the error + * message would take the form: + * + * stream_select(): unable to select [4]: Interrupted system call (max_fd=5) + * + * where [4] is the value of the macro EINTR and "Interrupted system + * call" is string which according to the Linux manual is "possibly" + * localised according to LC_MESSAGES. + */ + $eintr = defined( 'SOCKET_EINTR' ) ? SOCKET_EINTR : 4; + $eintrMessage = "stream_select(): unable to select [$eintr]"; + + $running = true; + $timeout = null; + $numReadyPipes = 0; + + while ( $running === true || $numReadyPipes !== 0 ) { + if ( $running ) { + $status = proc_get_status( $proc ); + // If the process has terminated, switch to nonblocking selects + // for getting any data still waiting to be read. + if ( !$status['running'] ) { + $running = false; + $timeout = 0; + } + } + + $readyPipes = $pipes; + + // Clear last error + // @codingStandardsIgnoreStart Generic.PHP.NoSilencedErrors.Discouraged + @trigger_error( '' ); + $numReadyPipes = @stream_select( $readyPipes, $emptyArray, $emptyArray, $timeout ); + if ( $numReadyPipes === false ) { + // @codingStandardsIgnoreEnd + $error = error_get_last(); + if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) { + continue; + } else { + trigger_error( $error['message'], E_USER_WARNING ); + $logMsg = $error['message']; + break; + } + } + foreach ( $readyPipes as $fd => $pipe ) { + $block = fread( $pipe, 65536 ); + if ( $block === '' ) { + // End of file + fclose( $pipes[$fd] ); + unset( $pipes[$fd] ); + if ( !$pipes ) { + break 2; + } + } elseif ( $block === false ) { + // Read error + $logMsg = "Error reading from pipe"; + break 2; + } elseif ( $fd == 1 ) { + // From stdout + $outBuffer .= $block; + } elseif ( $fd == 3 ) { + // From log FD + $logBuffer .= $block; + if ( strpos( $block, "\n" ) !== false ) { + $lines = explode( "\n", $logBuffer ); + $logBuffer = array_pop( $lines ); + foreach ( $lines as $line ) { + wfDebugLog( 'exec', $line ); + } + } + } + } + } + + foreach ( $pipes as $pipe ) { + fclose( $pipe ); + } + + // Use the status previously collected if possible, since proc_get_status() + // just calls waitpid() which will not return anything useful the second time. + if ( $running ) { + $status = proc_get_status( $proc ); + } + + if ( $logMsg !== false ) { + // Read/select error + $retval = -1; + proc_close( $proc ); + } elseif ( $status['signaled'] ) { + $logMsg = "Exited with signal {$status['termsig']}"; + $retval = 128 + $status['termsig']; + proc_close( $proc ); + } else { + if ( $status['running'] ) { + $retval = proc_close( $proc ); + } else { + $retval = $status['exitcode']; + proc_close( $proc ); + } + if ( $retval == 127 ) { + $logMsg = "Possibly missing executable file"; + } elseif ( $retval >= 129 && $retval <= 192 ) { + $logMsg = "Probably exited with signal " . ( $retval - 128 ); + } + } + + if ( $logMsg !== false ) { + wfDebugLog( 'exec', "$logMsg: $cmd" ); + } + + $this->output = $outBuffer; + $this->exitCode = $retval; + + return $this; + } + + public function getExitCode() { + return $this->exitCode; + } + + public function getOutput() { + return $this->output; + } + + public static function escape( /* ... */ ) { + self::initShellLocale(); + + $args = func_get_args(); + if ( count( $args ) === 1 && is_array( reset( $args ) ) ) { + // If only one argument has been passed, and that argument is an array, + // treat it as a list of arguments + $args = reset( $args ); + } + + $first = true; + $retVal = ''; + foreach ( $args as $arg ) { + if ( !$first ) { + $retVal .= ' '; + } else { + $first = false; + } + + if ( wfIsWindows() ) { + // Escaping for an MSVC-style command line parser and CMD.EXE + // @codingStandardsIgnoreStart For long URLs + // Refs: + // * http://web.archive.org/web/20020708081031/http://mailman.lyra.org/pipermail/scite-interest/2002-March/000436.html + // * http://technet.microsoft.com/en-us/library/cc723564.aspx + // * T15518 + // * CR r63214 + // Double the backslashes before any double quotes. Escape the double quotes. + // @codingStandardsIgnoreEnd + $tokens = preg_split( '/(\\\\*")/', $arg, -1, PREG_SPLIT_DELIM_CAPTURE ); + $arg = ''; + $iteration = 0; + foreach ( $tokens as $token ) { + if ( $iteration % 2 == 1 ) { + // Delimiter, a double quote preceded by zero or more slashes + $arg .= str_replace( '\\', '\\\\', substr( $token, 0, -1 ) ) . '\\"'; + } elseif ( $iteration % 4 == 2 ) { + // ^ in $token will be outside quotes, need to be escaped + $arg .= str_replace( '^', '^^', $token ); + } else { // $iteration % 4 == 0 + // ^ in $token will appear inside double quotes, so leave as is + $arg .= $token; + } + $iteration++; + } + // Double the backslashes before the end of the string, because + // we will soon add a quote + $m = []; + if ( preg_match( '/^(.*?)(\\\\+)$/', $arg, $m ) ) { + $arg = $m[1] . str_replace( '\\', '\\\\', $m[2] ); + } + + // Add surrounding quotes + $retVal .= '"' . $arg . '"'; + } else { + $retVal .= escapeshellarg( $arg ); + } + } + return $retVal; + } + + /** + * Workaround for http://bugs.php.net/bug.php?id=45132 + * escapeshellarg() destroys non-ASCII characters if LANG is not a UTF-8 locale + */ + private static function initShellLocale() { + static $done = false; + if ( $done ) { + return; + } + $done = true; + global $wgShellLocale; + putenv( "LC_CTYPE=$wgShellLocale" ); + setlocale( LC_CTYPE, $wgShellLocale ); + } +} -- To view, visit https://gerrit.wikimedia.org/r/319505 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I8ac9858b80d7908cf7e7981d7e19d0fc9c2265c0 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/core Gerrit-Branch: master Gerrit-Owner: MaxSem <maxsem.w...@gmail.com> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits