Legoktm has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/312463

Change subject: [WIP] Create ShellExec service
......................................................................

[WIP] Create ShellExec service

To replace most of the wfShell* global functions.

Change-Id: I7dccb2b67a4173a8a89b035e444fbda9102e4d0f
---
M includes/GlobalFunctions.php
M includes/MediaWikiServices.php
M includes/ServiceWiring.php
A includes/ShellExec.php
M tests/phpunit/includes/MediaWikiServicesTest.php
5 files changed, 484 insertions(+), 282 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core 
refs/changes/63/312463/1

diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php
index 6e8ce8f..fb5e34c 100644
--- a/includes/GlobalFunctions.php
+++ b/includes/GlobalFunctions.php
@@ -26,6 +26,7 @@
 
 use Liuggio\StatsdClient\Sender\SocketSender;
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
 use MediaWiki\Session\SessionManager;
 
 // Hide compatibility functions from Doxygen
@@ -2194,6 +2195,8 @@
 }
 
 /**
+ * @deprecated since 1.28, use ShellExec::escapeShellArg()
+ *
  * Windows-compatible version of escapeshellarg()
  * Windows doesn't recognise single-quotes in the shell, but the 
escapeshellarg()
  * function puts single quotes in regardless of OS.
@@ -2205,83 +2208,21 @@
  * @return string
  */
 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;
+       $shellExec = MediaWikiServices::getInstance()->getShellExec();
+       return call_user_func_array( [ $shellExec, 'escapeShellArg' ], 
func_get_args() );
 }
 
 /**
+ * @deprecated since 1.28, use ShellExec::isDisabled()
+ *
  * Check if wfShellExec() is effectively disabled via php.ini config
  *
  * @return bool|string False or 'disabled'
  * @since 1.22
  */
 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 MediaWikiServices::getInstance()->getShellExec()->isDisabled()
+               ? 'disabled' : false;
 }
 
 /**
@@ -2309,219 +2250,8 @@
 function wfShellExec( $cmd, &$retval = null, $environ = [],
        $limits = [], $options = []
 ) {
-       global $IP, $wgMaxShellMemory, $wgMaxShellFileSize, $wgMaxShellTime,
-               $wgMaxShellWallClockTime, $wgShellCgroup;
-
-       $disabled = wfShellExecDisabled();
-       if ( $disabled ) {
-               $retval = 1;
-               return 'Unable to run external programs, proc_open() is 
disabled.';
-       }
-
-       $includeStderr = isset( $options['duplicateStderr'] ) && 
$options['duplicateStderr'];
-       $profileMethod = isset( $options['profileMethod'] ) ? 
$options['profileMethod'] : wfGetCaller();
-
-       wfInitShellLocale();
-
-       $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 );
-       }
-
-       $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 MediaWikiServices::getInstance()->getShellExec()
+               ->shellExec( $cmd, $retval, $environ, $limits, $options );
 }
 
 /**
@@ -2546,6 +2276,8 @@
 }
 
 /**
+ * @deprecated since 1.28, using ShellExec will automatically call this
+ *
  * Workaround for http://bugs.php.net/bug.php?id=45132
  * escapeshellarg() destroys non-ASCII characters if LANG is not a UTF-8 locale
  */
diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php
index b16044e..0cb4a95 100644
--- a/includes/MediaWikiServices.php
+++ b/includes/MediaWikiServices.php
@@ -23,6 +23,7 @@
 use SearchEngine;
 use SearchEngineConfig;
 use SearchEngineFactory;
+use ShellExec;
 use SiteLookup;
 use SiteStore;
 use WatchedItemStore;
@@ -540,6 +541,14 @@
 
        /**
         * @since 1.28
+        * @return ShellExec
+        */
+       public function getShellExec() {
+               return $this->getService( 'ShellExec' );
+       }
+
+       /**
+        * @since 1.28
         * @return GenderCache
         */
        public function getGenderCache() {
diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php
index 6044911..dca785c 100644
--- a/includes/ServiceWiring.php
+++ b/includes/ServiceWiring.php
@@ -172,6 +172,27 @@
                );
        },
 
+       'ShellExec' => function( MediaWikiServices $services ) {
+               $mainConfig = $services->getMainConfig();
+               $profiler = Profiler::instance();
+               $shellExec = new ShellExec(
+                       [
+                               'time' => $mainConfig->get( 'MaxShellTime' ),
+                               'walltime' => $mainConfig->get( 
'MaxShellWallClockTime' ),
+                               'memory' => $mainConfig->get( 'MaxShellMemory' 
),
+                               'filesize' => $mainConfig->get( 
'MaxShellFileSize' ),
+                       ],
+                       $mainConfig->get( 'ShellLocale' ),
+                       $mainConfig->get( 'ShellCgroup' ),
+                       [ $profiler, 'scopedProfileIn' ]
+               );
+               $shellExec->setLogger(
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'exec' )
+               );
+
+               return $shellExec;
+       },
+
        'LinkCache' => function( MediaWikiServices $services ) {
                return new LinkCache(
                        $services->getTitleFormatter(),
diff --git a/includes/ShellExec.php b/includes/ShellExec.php
new file mode 100644
index 0000000..7950345
--- /dev/null
+++ b/includes/ShellExec.php
@@ -0,0 +1,439 @@
+<?php
+/**
+ * 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
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * @since 1.28
+ */
+class ShellExec implements LoggerAwareInterface {
+
+       /**
+        * @var string
+        */
+       private $shellLocale;
+
+       /**
+        * @var bool
+        */
+       private $setShellLocale = false;
+
+       /**
+        * @var array Default limits
+        */
+       private $defaultLimits;
+
+       /**
+        * @var string|bool
+        */
+       private $shellCGroup;
+
+       /**
+        * @var bool|callable
+        */
+       private $profileCallback;
+
+       /**
+        * @var LoggerInterface
+        */
+       private $logger;
+
+       /**
+        * @param array $limits array with Default limits(filesize, memory, 
time, walltime)
+        *   which can be overridden per shell exec.
+        * @param string $shellLocale
+        * @param string|bool $shellCGroup
+        * @param callable|bool $profileCallback
+        */
+       public function __construct( array $limits, $shellLocale = 
'en_US.utf8', $shellCGroup = false, $profileCallback = false ) {
+               $this->defaultLimits = $limits;
+               $this->shellLocale = $shellLocale;
+               $this->shellCGroup = $shellCGroup;
+               if ( $profileCallback !== false && !is_callable( 
$profileCallback ) ) {
+                       throw new InvalidArgumentException( '$profileCallback 
is not a valid callable' );
+               }
+               $this->profileCallback = $profileCallback;
+               $this->logger = new NullLogger();
+
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       /**
+        * Workaround for http://bugs.php.net/bug.php?id=45132
+        * escapeshellarg() destroys non-ASCII characters if LANG is not a 
UTF-8 locale
+        */
+       private function initShellLocale() {
+               if ( !$this->setShellLocale ) {
+                       $this->setShellLocale = true;
+                       putenv( "LC_CTYPE={$this->shellLocale}" );
+                       setlocale( LC_CTYPE, $this->shellLocale );
+               }
+       }
+
+       /**
+        * Check whether shelling out is effectively disabling via
+        * php.ini config
+        *
+        * @return bool
+        */
+       public function isDisabled() {
+               static $disabled = null;
+               if ( $disabled === null ) {
+                       if ( !function_exists( 'proc_open' ) ) {
+                               $this->logger->info( "proc_open() is 
disabled\n" );
+                               $disabled = true;
+                       } else {
+                               $disabled = false;
+                       }
+               }
+
+               return $disabled;
+       }
+
+       /**
+        * Whether we are running on Windows or not
+        *
+        * @return bool
+        */
+       private function isWindows() {
+               static $isWindows = null;
+               if ( $isWindows === null ) {
+                       $isWindows = strtoupper( substr( PHP_OS, 0, 3 ) ) === 
'WIN';
+               }
+               return $isWindows;
+       }
+
+       /**
+        * Windows-compatible version of escapeshellarg()
+        * Windows doesn't recognise single-quotes in the shell, but the 
escapeshellarg()
+        * function puts single quotes in regardless of OS.
+        *
+        * Also fixes the locale problems on Linux in PHP 5.2.6+ (bug 
backported to
+        * earlier distro releases of PHP)
+        *
+        * @param string ... strings to escape and glue together, or a single 
array of strings parameter
+        * @return string
+        */
+       public function escapeShellArg( /*...*/ ) {
+               $this->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 ( $this->isWindows() ) {
+                               // 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;
+       }
+
+
+       /**
+        * Execute a shell command, with time and memory limits mirrored from 
the PHP
+        * configuration if supported.
+        *
+        * @param string|string[] $cmd If string, a properly shell-escaped 
command line,
+        *   or an array of unescaped arguments, in which case each value will 
be escaped
+        *   Example:   [ 'convert', '-font', 'font name' ] would produce 
"'convert' '-font' 'font name'"
+        * @param null|mixed &$retval Optional, will receive the program's exit 
code.
+        *   (non-zero is usually failure). If there is an error from
+        *   read, select, or proc_open(), this will be set to -1.
+        * @param array $environ Optional environment variables which should be
+        *   added to the executed command environment.
+        * @param array $limits Optional array with limits(filesize, memory, 
time, walltime)
+        *   this overwrites the global wgMaxShell* limits.
+        * @param array $options Array of options:
+        *   - duplicateStderr: Set this to true to duplicate stderr to stdout,
+        *     including errors from limit.sh
+        *   - profileMethod: By default this function will profile based on 
the calling
+        *     method. Set this to a string for an alternative method to 
profile from
+        *
+        * @return string Collected stdout as a string
+        */
+       function shellExec( $cmd, &$retval = null, $environ = [],
+               $limits = [], $options = []
+       ) {
+               if ( $this->isDisabled() ) {
+                       $retval = 1;
+                       return 'Unable to run external programs, proc_open() is 
disabled.';
+               }
+
+               $includeStderr = isset( $options['duplicateStderr'] ) && 
$options['duplicateStderr'];
+               $profileMethod = isset( $options['profileMethod'] ) ? 
$options['profileMethod'] : wfGetCaller();
+
+               $this->initShellLocale();
+
+               $envcmd = '';
+               foreach ( $environ as $k => $v ) {
+                       if ( $this->isWindows() ) {
+                               /* 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 = $this->escapeShellArg( $cmd );
+               }
+
+               $cmd = $envcmd . $cmd;
+
+               $useLogPipe = false;
+               if ( is_executable( '/bin/bash' ) ) {
+                       $time = intval( isset( $limits['time'] ) ? 
$limits['time'] : $this->defaultLimits['time'] );
+                       if ( isset( $limits['walltime'] ) ) {
+                               $wallTime = intval( $limits['walltime'] );
+                       } elseif ( isset( $limits['time'] ) ) {
+                               $wallTime = $time;
+                       } else {
+                               $wallTime = intval( 
$this->defaultLimits['walltime'] );
+                       }
+                       $mem = intval( isset( $limits['memory'] ) ? 
$limits['memory'] : $this->defaultLimits['memory'] );
+                       $filesize = intval( isset( $limits['filesize'] ) ? 
$limits['filesize'] : $this->defaultLimits['filesize'] );
+
+                       if ( $time > 0 || $mem > 0 || $filesize > 0 || 
$wallTime > 0 ) {
+                               $limitScript = __DIR__ . '/limit.sh';
+                               $cmd = '/bin/bash ' . escapeshellarg( 
$limitScript ) . ' ' .
+                                       escapeshellarg( $cmd ) . ' ' .
+                                       escapeshellarg(
+                                               "MW_INCLUDE_STDERR=" . ( 
$includeStderr ? '1' : '' ) . ';' .
+                                               "MW_CPU_LIMIT=$time; " .
+                                               'MW_CGROUP=' . escapeshellarg( 
$this->shellCGroup ) . '; ' .
+                                               "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';
+               }
+               $this->logger->debug( "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;
+               if ( $this->profileCallback !== false ) {
+                       $scoped = call_user_func( $this->profileCallback, 
__METHOD__ . '-' . $profileMethod );
+               }
+               $proc = proc_open( $cmd, $desc, $pipes );
+               if ( !$proc ) {
+                       $this->logger->warning( '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 ) {
+                                                       $this->logger->info( 
'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 ) {
+                       $this->logger->info( 'exec', "$logMsg: $cmd" );
+               }
+
+               return $outBuffer;
+       }
+}
diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php 
b/tests/phpunit/includes/MediaWikiServicesTest.php
index a05e39d..9800bcb 100644
--- a/tests/phpunit/includes/MediaWikiServicesTest.php
+++ b/tests/phpunit/includes/MediaWikiServicesTest.php
@@ -321,7 +321,8 @@
                        'TitleFormatter' => [ 'TitleFormatter', 
TitleFormatter::class ],
                        'TitleParser' => [ 'TitleParser', TitleParser::class ],
                        'VirtualRESTServiceClient' => [ 
'VirtualRESTServiceClient', VirtualRESTServiceClient::class ],
-                       'ProxyLookup' => [ 'ProxyLookup', ProxyLookup::class ]
+                       'ProxyLookup' => [ 'ProxyLookup', ProxyLookup::class ],
+                       'ShellExec' => [ 'ShellExec', ShellExec::class ],
                ];
        }
 

-- 
To view, visit https://gerrit.wikimedia.org/r/312463
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I7dccb2b67a4173a8a89b035e444fbda9102e4d0f
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: Legoktm <legoktm.wikipe...@gmail.com>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to