MediaWiki  master
Command.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Shell;
22 
23 use Exception;
26 use Profiler;
27 use Psr\Log\LoggerAwareTrait;
28 use Psr\Log\NullLogger;
29 use Wikimedia\AtEase\AtEase;
30 
36 class Command {
37  use LoggerAwareTrait;
38 
40  protected $command = '';
41 
43  private $limits = [
44  // seconds
45  'time' => 180,
46  // seconds
47  'walltime' => 180,
48  // KB
49  'memory' => 307200,
50  // KB
51  'filesize' => 102400,
52  ];
53 
55  private $env = [];
56 
58  private $method;
59 
61  private $inputString = '';
62 
64  private $doIncludeStderr = false;
65 
67  private $doLogStderr = false;
68 
70  private $doPassStdin = false;
71 
73  private $doPassStderr = false;
74 
76  private $everExecuted = false;
77 
79  private $cgroup = false;
80 
86  protected $restrictions = 0;
87 
93  public function __construct() {
94  if ( Shell::isDisabled() ) {
95  throw new ShellDisabledError();
96  }
97 
98  $this->setLogger( new NullLogger() );
99  }
100 
104  public function __destruct() {
105  if ( !$this->everExecuted ) {
106  $context = [ 'command' => $this->command ];
107  $message = __CLASS__ . " was instantiated, but execute() was never called.";
108  if ( $this->method ) {
109  $message .= ' Calling method: {method}.';
110  $context['method'] = $this->method;
111  }
112  $message .= ' Command: {command}';
113  $this->logger->warning( $message, $context );
114  }
115  }
116 
124  public function params( ...$args ): Command {
125  if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
126  // If only one argument has been passed, and that argument is an array,
127  // treat it as a list of arguments
128  $args = reset( $args );
129  }
130  $this->command = trim( $this->command . ' ' . Shell::escape( $args ) );
131 
132  return $this;
133  }
134 
142  public function unsafeParams( ...$args ): Command {
143  if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
144  // If only one argument has been passed, and that argument is an array,
145  // treat it as a list of arguments
146  $args = reset( $args );
147  }
148  $args = array_filter( $args,
149  function ( $value ) {
150  return $value !== null;
151  }
152  );
153  $this->command = trim( $this->command . ' ' . implode( ' ', $args ) );
154 
155  return $this;
156  }
157 
165  public function limits( array $limits ): Command {
166  if ( !isset( $limits['walltime'] ) && isset( $limits['time'] ) ) {
167  // Emulate the behavior of old wfShellExec() where walltime fell back on time
168  // if the latter was overridden and the former wasn't
169  $limits['walltime'] = $limits['time'];
170  }
171  $this->limits = $limits + $this->limits;
172 
173  return $this;
174  }
175 
182  public function environment( array $env ): Command {
183  $this->env = $env;
184 
185  return $this;
186  }
187 
194  public function profileMethod( string $method ): Command {
195  $this->method = $method;
196 
197  return $this;
198  }
199 
208  public function input( string $inputString ): Command {
209  $this->inputString = $inputString;
210 
211  return $this;
212  }
213 
225  public function passStdin( bool $yesno = true ): Command {
226  $this->doPassStdin = $yesno;
227 
228  return $this;
229  }
230 
240  public function forwardStderr( bool $yesno = true ): Command {
241  $this->doPassStderr = $yesno;
242 
243  return $this;
244  }
245 
253  public function includeStderr( bool $yesno = true ): Command {
254  $this->doIncludeStderr = $yesno;
255 
256  return $this;
257  }
258 
265  public function logStderr( bool $yesno = true ): Command {
266  $this->doLogStderr = $yesno;
267 
268  return $this;
269  }
270 
277  public function cgroup( $cgroup ): Command {
278  $this->cgroup = $cgroup;
279 
280  return $this;
281  }
282 
305  public function restrict( int $restrictions ): Command {
306  $this->restrictions = $restrictions;
307 
308  return $this;
309  }
310 
318  protected function hasRestriction( int $restriction ): bool {
319  return ( $this->restrictions & $restriction ) === $restriction;
320  }
321 
332  public function whitelistPaths( array $paths ): Command {
333  // Default implementation is a no-op
334  return $this;
335  }
336 
344  protected function buildFinalCommand( string $command ): array {
345  $envcmd = '';
346  foreach ( $this->env as $k => $v ) {
347  if ( wfIsWindows() ) {
348  /* Surrounding a set in quotes (method used by wfEscapeShellArg) makes the quotes themselves
349  * appear in the environment variable, so we must use carat escaping as documented in
350  * https://technet.microsoft.com/en-us/library/cc723564.aspx
351  * Note however that the quote isn't listed there, but is needed, and the parentheses
352  * are listed there but doesn't appear to need it.
353  */
354  $envcmd .= "set $k=" . preg_replace( '/([&|()<>^"])/', '^\\1', $v ) . '&& ';
355  } else {
356  /* Assume this is a POSIX shell, thus required to accept variable assignments before the command
357  * http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_09_01
358  */
359  $envcmd .= "$k=" . escapeshellarg( $v ) . ' ';
360  }
361  }
362 
363  $useLogPipe = false;
364  $cmd = $envcmd . trim( $command );
365 
366  if ( is_executable( '/bin/bash' ) ) {
367  $time = intval( $this->limits['time'] );
368  $wallTime = $this->doPassStdin ? 0 : intval( $this->limits['walltime'] );
369  $mem = intval( $this->limits['memory'] );
370  $filesize = intval( $this->limits['filesize'] );
371 
372  if ( $time > 0 || $mem > 0 || $filesize > 0 || $wallTime > 0 ) {
373  $cmd = '/bin/bash ' . escapeshellarg( __DIR__ . '/limit.sh' ) . ' ' .
374  escapeshellarg( $cmd ) . ' ' .
375  escapeshellarg(
376  "MW_INCLUDE_STDERR=" . ( $this->doIncludeStderr ? '1' : '' ) . ';' .
377  "MW_CPU_LIMIT=$time; " .
378  'MW_CGROUP=' . escapeshellarg( $this->cgroup ) . '; ' .
379  "MW_MEM_LIMIT=$mem; " .
380  "MW_FILE_SIZE_LIMIT=$filesize; " .
381  "MW_WALL_CLOCK_LIMIT=$wallTime; " .
382  "MW_USE_LOG_PIPE=yes"
383  );
384  $useLogPipe = true;
385  }
386  }
387  if ( !$useLogPipe && $this->doIncludeStderr ) {
388  $cmd .= ' 2>&1';
389  }
390 
391  if ( wfIsWindows() ) {
392  $cmd = 'cmd.exe /c "' . $cmd . '"';
393  }
394 
395  return [ $cmd, $useLogPipe ];
396  }
397 
407  public function execute(): Result {
408  $this->everExecuted = true;
409 
410  $profileMethod = $this->method ?: wfGetCaller();
411 
412  list( $cmd, $useLogPipe ) = $this->buildFinalCommand( $this->command );
413 
414  $this->logger->debug( __METHOD__ . ": $cmd" );
415 
416  // Don't try to execute commands that exceed Linux's MAX_ARG_STRLEN.
417  // Other platforms may be more accomodating, but we don't want to be
418  // accomodating, because very long commands probably include user
419  // input. See T129506.
420  if ( strlen( $cmd ) > SHELL_MAX_ARG_STRLEN ) {
421  throw new Exception( __METHOD__ .
422  '(): total length of $cmd must not exceed SHELL_MAX_ARG_STRLEN' );
423  }
424 
425  $desc = [
426  0 => $this->doPassStdin ? [ 'file', 'php://stdin', 'r' ] : [ 'pipe', 'r' ],
427  1 => [ 'pipe', 'w' ],
428  2 => [ 'pipe', 'w' ],
429  ];
430  if ( $useLogPipe ) {
431  $desc[3] = [ 'pipe', 'w' ];
432  }
433  $pipes = null;
434  $scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod );
435  $proc = null;
436 
437  if ( wfIsWindows() ) {
438  // Windows Shell bypassed, but command run is "cmd.exe /C "{$cmd}"
439  // This solves some shell parsing issues, see T207248
440  $proc = proc_open( $cmd, $desc, $pipes, null, null, [ 'bypass_shell' => true ] );
441  } else {
442  $proc = proc_open( $cmd, $desc, $pipes );
443  }
444 
445  if ( !$proc ) {
446  $this->logger->error( "proc_open() failed: {command}", [ 'command' => $cmd ] );
447  throw new ProcOpenError();
448  }
449 
450  $buffers = [
451  0 => $this->inputString, // input
452  1 => '', // stdout
453  2 => null, // stderr
454  3 => '', // log
455  ];
456  $emptyArray = [];
457  $status = false;
458  $logMsg = false;
459 
460  /* According to the documentation, it is possible for stream_select()
461  * to fail due to EINTR. I haven't managed to induce this in testing
462  * despite sending various signals. If it did happen, the error
463  * message would take the form:
464  *
465  * stream_select(): unable to select [4]: Interrupted system call (max_fd=5)
466  *
467  * where [4] is the value of the macro EINTR and "Interrupted system
468  * call" is string which according to the Linux manual is "possibly"
469  * localised according to LC_MESSAGES.
470  */
471  $eintr = defined( 'SOCKET_EINTR' ) ? SOCKET_EINTR : 4;
472  $eintrMessage = "stream_select(): unable to select [$eintr]";
473 
474  /* The select(2) system call only guarantees a "sufficiently small write"
475  * can be made without blocking. And on Linux the read might block too
476  * in certain cases, although I don't know if any of them can occur here.
477  * Regardless, set all the pipes to non-blocking to avoid T184171.
478  */
479  foreach ( $pipes as $pipe ) {
480  stream_set_blocking( $pipe, false );
481  }
482 
483  $running = true;
484  $timeout = null;
485  $numReadyPipes = 0;
486 
487  while ( $pipes && ( $running === true || $numReadyPipes !== 0 ) ) {
488  if ( $running ) {
489  $status = proc_get_status( $proc );
490  // If the process has terminated, switch to nonblocking selects
491  // for getting any data still waiting to be read.
492  if ( !$status['running'] ) {
493  $running = false;
494  $timeout = 0;
495  }
496  }
497 
498  error_clear_last();
499 
500  $readPipes = array_filter( $pipes, function ( $fd ) use ( $desc ) {
501  return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'r';
502  }, ARRAY_FILTER_USE_KEY );
503  $writePipes = array_filter( $pipes, function ( $fd ) use ( $desc ) {
504  return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'w';
505  }, ARRAY_FILTER_USE_KEY );
506  // stream_select parameter names are from the POV of us being able to do the operation;
507  // proc_open desriptor types are from the POV of the process doing it.
508  // So $writePipes is passed as the $read parameter and $readPipes as $write.
509  AtEase::suppressWarnings();
510  $numReadyPipes = stream_select( $writePipes, $readPipes, $emptyArray, $timeout );
511  AtEase::restoreWarnings();
512  if ( $numReadyPipes === false ) {
513  $error = error_get_last();
514  if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) {
515  continue;
516  } else {
517  trigger_error( $error['message'], E_USER_WARNING );
518  $logMsg = $error['message'];
519  break;
520  }
521  }
522  foreach ( $writePipes + $readPipes as $fd => $pipe ) {
523  // True if a pipe is unblocked for us to write into, false if for reading from
524  $isWrite = array_key_exists( $fd, $readPipes );
525 
526  if ( $isWrite ) {
527  // Don't bother writing if the buffer is empty
528  if ( $buffers[$fd] === '' ) {
529  fclose( $pipes[$fd] );
530  unset( $pipes[$fd] );
531  continue;
532  }
533  $res = fwrite( $pipe, $buffers[$fd], 65536 );
534  } else {
535  $res = fread( $pipe, 65536 );
536  }
537 
538  if ( $res === false ) {
539  $logMsg = 'Error ' . ( $isWrite ? 'writing to' : 'reading from' ) . ' pipe';
540  break 2;
541  }
542 
543  if ( $res === '' || $res === 0 ) {
544  // End of file?
545  if ( feof( $pipe ) ) {
546  fclose( $pipes[$fd] );
547  unset( $pipes[$fd] );
548  }
549  } elseif ( $isWrite ) {
550  $buffers[$fd] = (string)substr( $buffers[$fd], $res );
551  if ( $buffers[$fd] === '' ) {
552  fclose( $pipes[$fd] );
553  unset( $pipes[$fd] );
554  }
555  } else {
556  $buffers[$fd] .= $res;
557  if ( $fd === 3 && strpos( $res, "\n" ) !== false ) {
558  // For the log FD, every line is a separate log entry.
559  $lines = explode( "\n", $buffers[3] );
560  $buffers[3] = array_pop( $lines );
561  foreach ( $lines as $line ) {
562  $this->logger->info( $line );
563  }
564  }
565  if ( $fd === 2 && $this->doPassStderr ) {
566  fwrite( STDERR, $res );
567  }
568  }
569  }
570  }
571 
572  foreach ( $pipes as $pipe ) {
573  fclose( $pipe );
574  }
575 
576  // Use the status previously collected if possible, since proc_get_status()
577  // just calls waitpid() which will not return anything useful the second time.
578  if ( $running ) {
579  $status = proc_get_status( $proc );
580  }
581 
582  if ( $logMsg !== false ) {
583  // Read/select error
584  $retval = -1;
585  proc_close( $proc );
586  } elseif ( $status['signaled'] ) {
587  $logMsg = "Exited with signal {$status['termsig']}";
588  $retval = 128 + $status['termsig'];
589  proc_close( $proc );
590  } else {
591  if ( $status['running'] ) {
592  $retval = proc_close( $proc );
593  } else {
594  $retval = $status['exitcode'];
595  proc_close( $proc );
596  }
597  if ( $retval == 127 ) {
598  $logMsg = "Possibly missing executable file";
599  } elseif ( $retval >= 129 && $retval <= 192 ) {
600  $logMsg = "Probably exited with signal " . ( $retval - 128 );
601  }
602  }
603 
604  if ( $logMsg !== false ) {
605  $this->logger->warning( "$logMsg: {command}", [ 'command' => $cmd ] );
606  }
607 
608  // @phan-suppress-next-line PhanImpossibleCondition
609  if ( $buffers[2] && $this->doLogStderr ) {
610  $this->logger->error( "Error running {command}: {error}", [
611  'command' => $cmd,
612  'error' => $buffers[2],
613  'exitcode' => $retval,
614  'exception' => new Exception( 'Shell error' ),
615  ] );
616  }
617 
618  return new Result( $retval, $buffers[1], $buffers[2] );
619  }
620 
628  public function __toString(): string {
629  return "#Command: {$this->command}";
630  }
631 }
MediaWiki\Shell\Command\passStdin
passStdin(bool $yesno=true)
Controls whether stdin is passed through to the command, so that the user can interact with the comma...
Definition: Command.php:225
MediaWiki\Shell\Command\$everExecuted
bool $everExecuted
Definition: Command.php:76
MediaWiki\Shell\Result
Returned by MediaWiki\Shell\Command::execute()
Definition: Result.php:30
MediaWiki\Shell\Command\$doIncludeStderr
bool $doIncludeStderr
Definition: Command.php:64
MediaWiki\Shell\Command\__destruct
__destruct()
Makes sure the programmer didn't forget to execute the command after all.
Definition: Command.php:104
MediaWiki\Shell\Command\unsafeParams
unsafeParams(... $args)
Adds unsafe parameters to the command.
Definition: Command.php:142
MediaWiki\ProcOpenError
@newable
Definition: ProcOpenError.php:28
MediaWiki\Shell\Command\buildFinalCommand
buildFinalCommand(string $command)
String together all the options and build the final command to execute.
Definition: Command.php:344
Profiler\instance
static instance()
Singleton.
Definition: Profiler.php:69
MediaWiki\Shell\Command
Class used for executing shell commands.
Definition: Command.php:36
MediaWiki\Shell\Command\$doPassStderr
bool $doPassStderr
Definition: Command.php:73
MediaWiki\Shell\Command\cgroup
cgroup( $cgroup)
Sets cgroup for this command.
Definition: Command.php:277
MediaWiki\Shell\Command\limits
limits(array $limits)
Sets execution limits.
Definition: Command.php:165
MediaWiki\Shell\Command\$restrictions
int $restrictions
Bitfield with restrictions.
Definition: Command.php:86
$res
$res
Definition: testCompression.php:57
MediaWiki\Shell\Command\$inputString
string $inputString
Definition: Command.php:61
MediaWiki\Shell\Command\logStderr
logStderr(bool $yesno=true)
When enabled, text sent to stderr will be logged with a level of 'error'.
Definition: Command.php:265
MediaWiki\Shell\Command\includeStderr
includeStderr(bool $yesno=true)
Controls whether stderr should be included in stdout, including errors from limit....
Definition: Command.php:253
Profiler
Profiler base class that defines the interface and some shared functionality.
Definition: Profiler.php:36
SHELL_MAX_ARG_STRLEN
const SHELL_MAX_ARG_STRLEN
Definition: Defines.php:260
$args
if( $line===false) $args
Definition: mcc.php:124
MediaWiki\Shell\Command\$env
string[] $env
Definition: Command.php:55
MediaWiki\Shell\Command\__toString
__toString()
Returns the final command line before environment/limiting, etc are applied.
Definition: Command.php:628
MediaWiki\Shell\Command\environment
environment(array $env)
Sets environment variables which should be added to the executed command environment.
Definition: Command.php:182
MediaWiki\Shell\Shell\isDisabled
static isDisabled()
Check if this class is effectively disabled via php.ini config.
Definition: Shell.php:137
MediaWiki\Shell\Command\profileMethod
profileMethod(string $method)
Sets calling function for profiler.
Definition: Command.php:194
MediaWiki\Shell\Command\$command
string $command
Definition: Command.php:40
MediaWiki\Shell\Command\$doLogStderr
bool $doLogStderr
Definition: Command.php:67
MediaWiki\Shell\Command\forwardStderr
forwardStderr(bool $yesno=true)
If this is set to true, text written to stderr by the command will be passed through to PHP's stderr.
Definition: Command.php:240
wfIsWindows
wfIsWindows()
Check if the operating system is Windows.
Definition: GlobalFunctions.php:1856
MediaWiki\Shell\Command\hasRestriction
hasRestriction(int $restriction)
Bitfield helper on whether a specific restriction is enabled.
Definition: Command.php:318
$line
$line
Definition: mcc.php:119
MediaWiki\ShellDisabledError
@newable
Definition: ShellDisabledError.php:29
MediaWiki\Shell\Shell\escape
static escape(... $args)
Version of escapeshellarg() that works better on Windows.
Definition: Shell.php:163
$lines
if(!file_exists( $CREDITS)) $lines
Definition: updateCredits.php:43
MediaWiki\Shell
Copyright (C) 2017 Kunal Mehta legoktm@member.fsf.org
Definition: Command.php:21
MediaWiki\Shell\Command\$limits
array $limits
Definition: Command.php:43
MediaWiki\Shell\Command\input
input(string $inputString)
Sends the provided input to the command.
Definition: Command.php:208
MediaWiki\Shell\Command\restrict
restrict(int $restrictions)
Set restrictions for this request, overwriting any previously set restrictions.
Definition: Command.php:305
MediaWiki\Shell\Command\$cgroup
string false $cgroup
Definition: Command.php:79
MediaWiki\Shell\Command\$method
string $method
Definition: Command.php:58
MediaWiki\$context
IContextSource $context
Definition: MediaWiki.php:40
MediaWiki\Shell\Command\whitelistPaths
whitelistPaths(array $paths)
If called, only the files/directories that are whitelisted will be available to the shell command.
Definition: Command.php:332
MediaWiki\Shell\Command\execute
execute()
Executes command.
Definition: Command.php:407
wfGetCaller
wfGetCaller( $level=2)
Get the name of the function which called this function wfGetCaller( 1 ) is the function with the wfG...
Definition: GlobalFunctions.php:1400
MediaWiki\Shell\Command\__construct
__construct()
Don't call directly, instead use Shell::command()
Definition: Command.php:93
MediaWiki\Shell\Command\params
params(... $args)
Adds parameters to the command.
Definition: Command.php:124
MediaWiki\Shell\Command\$doPassStdin
bool $doPassStdin
Definition: Command.php:70