1<?php 2/** 3 * This program is free software; you can redistribute it and/or modify 4 * it under the terms of the GNU General Public License as published by 5 * the Free Software Foundation; either version 2 of the License, or 6 * (at your option) any later version. 7 * 8 * This program is distributed in the hope that it will be useful, 9 * but WITHOUT ANY WARRANTY; without even the implied warranty of 10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 * GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along 14 * with this program; if not, write to the Free Software Foundation, Inc., 15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 * http://www.gnu.org/copyleft/gpl.html 17 * 18 * @file 19 */ 20 21namespace MediaWiki\Shell; 22 23use Exception; 24use MediaWiki\ProcOpenError; 25use MediaWiki\ShellDisabledError; 26use Profiler; 27use Psr\Log\LoggerAwareTrait; 28use Psr\Log\NullLogger; 29use Wikimedia\AtEase\AtEase; 30 31/** 32 * Class used for executing shell commands 33 * 34 * @since 1.30 35 */ 36class Command { 37 use LoggerAwareTrait; 38 39 /** @var string */ 40 protected $command = ''; 41 42 /** @var array */ 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 54 /** @var string[] */ 55 private $env = []; 56 57 /** @var string */ 58 private $method; 59 60 /** @var string|null */ 61 private $inputString; 62 63 /** @var bool */ 64 private $doIncludeStderr = false; 65 66 /** @var bool */ 67 private $doLogStderr = false; 68 69 /** @var bool */ 70 private $everExecuted = false; 71 72 /** @var string|false */ 73 private $cgroup = false; 74 75 /** 76 * Bitfield with restrictions 77 * 78 * @var int 79 */ 80 protected $restrictions = 0; 81 82 /** 83 * Don't call directly, instead use Shell::command() 84 * 85 * @throws ShellDisabledError 86 */ 87 public function __construct() { 88 if ( Shell::isDisabled() ) { 89 throw new ShellDisabledError(); 90 } 91 92 $this->setLogger( new NullLogger() ); 93 } 94 95 /** 96 * Makes sure the programmer didn't forget to execute the command after all 97 */ 98 public function __destruct() { 99 if ( !$this->everExecuted ) { 100 $context = [ 'command' => $this->command ]; 101 $message = __CLASS__ . " was instantiated, but execute() was never called."; 102 if ( $this->method ) { 103 $message .= ' Calling method: {method}.'; 104 $context['method'] = $this->method; 105 } 106 $message .= ' Command: {command}'; 107 $this->logger->warning( $message, $context ); 108 } 109 } 110 111 /** 112 * Adds parameters to the command. All parameters are sanitized via Shell::escape(). 113 * Null values are ignored. 114 * 115 * @param string|string[] ...$args 116 * @return $this 117 */ 118 public function params( ...$args ): Command { 119 if ( count( $args ) === 1 && is_array( reset( $args ) ) ) { 120 // If only one argument has been passed, and that argument is an array, 121 // treat it as a list of arguments 122 $args = reset( $args ); 123 } 124 $this->command = trim( $this->command . ' ' . Shell::escape( $args ) ); 125 126 return $this; 127 } 128 129 /** 130 * Adds unsafe parameters to the command. These parameters are NOT sanitized in any way. 131 * Null values are ignored. 132 * 133 * @param string|string[] ...$args 134 * @return $this 135 */ 136 public function unsafeParams( ...$args ): Command { 137 if ( count( $args ) === 1 && is_array( reset( $args ) ) ) { 138 // If only one argument has been passed, and that argument is an array, 139 // treat it as a list of arguments 140 $args = reset( $args ); 141 } 142 $args = array_filter( $args, 143 function ( $value ) { 144 return $value !== null; 145 } 146 ); 147 $this->command = trim( $this->command . ' ' . implode( ' ', $args ) ); 148 149 return $this; 150 } 151 152 /** 153 * Sets execution limits 154 * 155 * @param array $limits Associative array of limits. Keys (all optional): 156 * filesize (for ulimit -f), memory, time, walltime. 157 * @return $this 158 */ 159 public function limits( array $limits ): Command { 160 if ( !isset( $limits['walltime'] ) && isset( $limits['time'] ) ) { 161 // Emulate the behavior of old wfShellExec() where walltime fell back on time 162 // if the latter was overridden and the former wasn't 163 $limits['walltime'] = $limits['time']; 164 } 165 $this->limits = $limits + $this->limits; 166 167 return $this; 168 } 169 170 /** 171 * Sets environment variables which should be added to the executed command environment 172 * 173 * @param string[] $env array of variable name => value 174 * @return $this 175 */ 176 public function environment( array $env ): Command { 177 $this->env = $env; 178 179 return $this; 180 } 181 182 /** 183 * Sets calling function for profiler. By default, the caller for execute() will be used. 184 * 185 * @param string $method 186 * @return $this 187 */ 188 public function profileMethod( string $method ): Command { 189 $this->method = $method; 190 191 return $this; 192 } 193 194 /** 195 * Sends the provided input to the command. 196 * When set to null (default), the command will use the standard input. 197 * @param string|null $inputString 198 * @return $this 199 */ 200 public function input( ?string $inputString ): Command { 201 $this->inputString = $inputString; 202 203 return $this; 204 } 205 206 /** 207 * Controls whether stderr should be included in stdout, including errors from limit.sh. 208 * Default: don't include. 209 * 210 * @param bool $yesno 211 * @return $this 212 */ 213 public function includeStderr( bool $yesno = true ): Command { 214 $this->doIncludeStderr = $yesno; 215 216 return $this; 217 } 218 219 /** 220 * When enabled, text sent to stderr will be logged with a level of 'error'. 221 * 222 * @param bool $yesno 223 * @return $this 224 */ 225 public function logStderr( bool $yesno = true ): Command { 226 $this->doLogStderr = $yesno; 227 228 return $this; 229 } 230 231 /** 232 * Sets cgroup for this command 233 * 234 * @param string|false $cgroup Absolute file path to the cgroup, or false to not use a cgroup 235 * @return $this 236 */ 237 public function cgroup( $cgroup ): Command { 238 $this->cgroup = $cgroup; 239 240 return $this; 241 } 242 243 /** 244 * Set restrictions for this request, overwriting any previously set restrictions. 245 * 246 * Add the "no network" restriction: 247 * @code 248 * $command->restrict( Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK ); 249 * @endcode 250 * 251 * Allow LocalSettings.php access: 252 * @code 253 * $command->restrict( Shell::RESTRICT_DEFAULT & ~Shell::NO_LOCALSETTINGS ); 254 * @endcode 255 * 256 * Disable all restrictions: 257 * @code 258 * $command->restrict( Shell::RESTRICT_NONE ); 259 * @endcode 260 * 261 * @since 1.31 262 * @param int $restrictions 263 * @return $this 264 */ 265 public function restrict( int $restrictions ): Command { 266 $this->restrictions = $restrictions; 267 268 return $this; 269 } 270 271 /** 272 * Bitfield helper on whether a specific restriction is enabled 273 * 274 * @param int $restriction 275 * 276 * @return bool 277 */ 278 protected function hasRestriction( int $restriction ): bool { 279 return ( $this->restrictions & $restriction ) === $restriction; 280 } 281 282 /** 283 * If called, only the files/directories that are 284 * whitelisted will be available to the shell command. 285 * 286 * limit.sh will always be whitelisted 287 * 288 * @param string[] $paths 289 * 290 * @return $this 291 */ 292 public function whitelistPaths( array $paths ): Command { 293 // Default implementation is a no-op 294 return $this; 295 } 296 297 /** 298 * String together all the options and build the final command 299 * to execute 300 * 301 * @param string $command Already-escaped command to run 302 * @return array [ command, whether to use log pipe ] 303 */ 304 protected function buildFinalCommand( string $command ): array { 305 $envcmd = ''; 306 foreach ( $this->env as $k => $v ) { 307 if ( wfIsWindows() ) { 308 /* Surrounding a set in quotes (method used by wfEscapeShellArg) makes the quotes themselves 309 * appear in the environment variable, so we must use carat escaping as documented in 310 * https://technet.microsoft.com/en-us/library/cc723564.aspx 311 * Note however that the quote isn't listed there, but is needed, and the parentheses 312 * are listed there but doesn't appear to need it. 313 */ 314 $envcmd .= "set $k=" . preg_replace( '/([&|()<>^"])/', '^\\1', $v ) . '&& '; 315 } else { 316 /* Assume this is a POSIX shell, thus required to accept variable assignments before the command 317 * http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_09_01 318 */ 319 $envcmd .= "$k=" . escapeshellarg( $v ) . ' '; 320 } 321 } 322 323 $useLogPipe = false; 324 $cmd = $envcmd . trim( $command ); 325 326 if ( is_executable( '/bin/bash' ) ) { 327 $time = intval( $this->limits['time'] ); 328 $wallTime = intval( $this->limits['walltime'] ); 329 $mem = intval( $this->limits['memory'] ); 330 $filesize = intval( $this->limits['filesize'] ); 331 332 if ( $time > 0 || $mem > 0 || $filesize > 0 || $wallTime > 0 ) { 333 $cmd = '/bin/bash ' . escapeshellarg( __DIR__ . '/limit.sh' ) . ' ' . 334 escapeshellarg( $cmd ) . ' ' . 335 escapeshellarg( 336 "MW_INCLUDE_STDERR=" . ( $this->doIncludeStderr ? '1' : '' ) . ';' . 337 "MW_CPU_LIMIT=$time; " . 338 'MW_CGROUP=' . escapeshellarg( $this->cgroup ) . '; ' . 339 "MW_MEM_LIMIT=$mem; " . 340 "MW_FILE_SIZE_LIMIT=$filesize; " . 341 "MW_WALL_CLOCK_LIMIT=$wallTime; " . 342 "MW_USE_LOG_PIPE=yes" 343 ); 344 $useLogPipe = true; 345 } 346 } 347 if ( !$useLogPipe && $this->doIncludeStderr ) { 348 $cmd .= ' 2>&1'; 349 } 350 351 if ( wfIsWindows() ) { 352 $cmd = 'cmd.exe /c "' . $cmd . '"'; 353 } 354 355 return [ $cmd, $useLogPipe ]; 356 } 357 358 /** 359 * Executes command. Afterwards, getExitCode() and getOutput() can be used to access execution 360 * results. 361 * 362 * @return Result 363 * @throws Exception 364 * @throws ProcOpenError 365 * @throws ShellDisabledError 366 */ 367 public function execute(): Result { 368 $this->everExecuted = true; 369 370 $profileMethod = $this->method ?: wfGetCaller(); 371 372 list( $cmd, $useLogPipe ) = $this->buildFinalCommand( $this->command ); 373 374 $this->logger->debug( __METHOD__ . ": $cmd" ); 375 376 // Don't try to execute commands that exceed Linux's MAX_ARG_STRLEN. 377 // Other platforms may be more accomodating, but we don't want to be 378 // accomodating, because very long commands probably include user 379 // input. See T129506. 380 if ( strlen( $cmd ) > SHELL_MAX_ARG_STRLEN ) { 381 throw new Exception( __METHOD__ . 382 '(): total length of $cmd must not exceed SHELL_MAX_ARG_STRLEN' ); 383 } 384 385 $desc = [ 386 0 => $this->inputString === null ? [ 'file', 'php://stdin', 'r' ] : [ 'pipe', 'r' ], 387 1 => [ 'pipe', 'w' ], 388 2 => [ 'pipe', 'w' ], 389 ]; 390 if ( $useLogPipe ) { 391 $desc[3] = [ 'pipe', 'w' ]; 392 } 393 $pipes = null; 394 $scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod ); 395 $proc = null; 396 397 if ( wfIsWindows() ) { 398 // Windows Shell bypassed, but command run is "cmd.exe /C "{$cmd}" 399 // This solves some shell parsing issues, see T207248 400 $proc = proc_open( $cmd, $desc, $pipes, null, null, [ 'bypass_shell' => true ] ); 401 } else { 402 $proc = proc_open( $cmd, $desc, $pipes ); 403 } 404 405 if ( !$proc ) { 406 $this->logger->error( "proc_open() failed: {command}", [ 'command' => $cmd ] ); 407 throw new ProcOpenError(); 408 } 409 410 $buffers = [ 411 0 => $this->inputString, // input 412 1 => '', // stdout 413 2 => null, // stderr 414 3 => '', // log 415 ]; 416 $emptyArray = []; 417 $status = false; 418 $logMsg = false; 419 420 /* According to the documentation, it is possible for stream_select() 421 * to fail due to EINTR. I haven't managed to induce this in testing 422 * despite sending various signals. If it did happen, the error 423 * message would take the form: 424 * 425 * stream_select(): unable to select [4]: Interrupted system call (max_fd=5) 426 * 427 * where [4] is the value of the macro EINTR and "Interrupted system 428 * call" is string which according to the Linux manual is "possibly" 429 * localised according to LC_MESSAGES. 430 */ 431 $eintr = defined( 'SOCKET_EINTR' ) ? SOCKET_EINTR : 4; 432 $eintrMessage = "stream_select(): unable to select [$eintr]"; 433 434 /* The select(2) system call only guarantees a "sufficiently small write" 435 * can be made without blocking. And on Linux the read might block too 436 * in certain cases, although I don't know if any of them can occur here. 437 * Regardless, set all the pipes to non-blocking to avoid T184171. 438 */ 439 foreach ( $pipes as $pipe ) { 440 stream_set_blocking( $pipe, false ); 441 } 442 443 $running = true; 444 $timeout = null; 445 $numReadyPipes = 0; 446 447 while ( $pipes && ( $running === true || $numReadyPipes !== 0 ) ) { 448 if ( $running ) { 449 $status = proc_get_status( $proc ); 450 // If the process has terminated, switch to nonblocking selects 451 // for getting any data still waiting to be read. 452 if ( !$status['running'] ) { 453 $running = false; 454 $timeout = 0; 455 } 456 } 457 458 error_clear_last(); 459 460 $readPipes = array_filter( $pipes, function ( $fd ) use ( $desc ) { 461 return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'r'; 462 }, ARRAY_FILTER_USE_KEY ); 463 $writePipes = array_filter( $pipes, function ( $fd ) use ( $desc ) { 464 return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'w'; 465 }, ARRAY_FILTER_USE_KEY ); 466 // stream_select parameter names are from the POV of us being able to do the operation; 467 // proc_open desriptor types are from the POV of the process doing it. 468 // So $writePipes is passed as the $read parameter and $readPipes as $write. 469 AtEase::suppressWarnings(); 470 $numReadyPipes = stream_select( $writePipes, $readPipes, $emptyArray, $timeout ); 471 AtEase::restoreWarnings(); 472 if ( $numReadyPipes === false ) { 473 $error = error_get_last(); 474 if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) { 475 continue; 476 } else { 477 trigger_error( $error['message'], E_USER_WARNING ); 478 $logMsg = $error['message']; 479 break; 480 } 481 } 482 foreach ( $writePipes + $readPipes as $fd => $pipe ) { 483 // True if a pipe is unblocked for us to write into, false if for reading from 484 $isWrite = array_key_exists( $fd, $readPipes ); 485 486 if ( $isWrite ) { 487 // Don't bother writing if the buffer is empty 488 if ( $buffers[$fd] === '' ) { 489 fclose( $pipes[$fd] ); 490 unset( $pipes[$fd] ); 491 continue; 492 } 493 $res = fwrite( $pipe, $buffers[$fd], 65536 ); 494 } else { 495 $res = fread( $pipe, 65536 ); 496 } 497 498 if ( $res === false ) { 499 $logMsg = 'Error ' . ( $isWrite ? 'writing to' : 'reading from' ) . ' pipe'; 500 break 2; 501 } 502 503 if ( $res === '' || $res === 0 ) { 504 // End of file? 505 if ( feof( $pipe ) ) { 506 fclose( $pipes[$fd] ); 507 unset( $pipes[$fd] ); 508 } 509 } elseif ( $isWrite ) { 510 $buffers[$fd] = (string)substr( $buffers[$fd], $res ); 511 if ( $buffers[$fd] === '' ) { 512 fclose( $pipes[$fd] ); 513 unset( $pipes[$fd] ); 514 } 515 } else { 516 $buffers[$fd] .= $res; 517 if ( $fd === 3 && strpos( $res, "\n" ) !== false ) { 518 // For the log FD, every line is a separate log entry. 519 $lines = explode( "\n", $buffers[3] ); 520 $buffers[3] = array_pop( $lines ); 521 foreach ( $lines as $line ) { 522 $this->logger->info( $line ); 523 } 524 } 525 } 526 } 527 } 528 529 foreach ( $pipes as $pipe ) { 530 fclose( $pipe ); 531 } 532 533 // Use the status previously collected if possible, since proc_get_status() 534 // just calls waitpid() which will not return anything useful the second time. 535 if ( $running ) { 536 $status = proc_get_status( $proc ); 537 } 538 539 if ( $logMsg !== false ) { 540 // Read/select error 541 $retval = -1; 542 proc_close( $proc ); 543 } elseif ( $status['signaled'] ) { 544 $logMsg = "Exited with signal {$status['termsig']}"; 545 $retval = 128 + $status['termsig']; 546 proc_close( $proc ); 547 } else { 548 if ( $status['running'] ) { 549 $retval = proc_close( $proc ); 550 } else { 551 $retval = $status['exitcode']; 552 proc_close( $proc ); 553 } 554 if ( $retval == 127 ) { 555 $logMsg = "Possibly missing executable file"; 556 } elseif ( $retval >= 129 && $retval <= 192 ) { 557 $logMsg = "Probably exited with signal " . ( $retval - 128 ); 558 } 559 } 560 561 if ( $logMsg !== false ) { 562 $this->logger->warning( "$logMsg: {command}", [ 'command' => $cmd ] ); 563 } 564 565 // @phan-suppress-next-line PhanImpossibleCondition 566 if ( $buffers[2] && $this->doLogStderr ) { 567 $this->logger->error( "Error running {command}: {error}", [ 568 'command' => $cmd, 569 'error' => $buffers[2], 570 'exitcode' => $retval, 571 'exception' => new Exception( 'Shell error' ), 572 ] ); 573 } 574 575 return new Result( $retval, $buffers[1], $buffers[2] ); 576 } 577 578 /** 579 * Returns the final command line before environment/limiting, etc are applied. 580 * Use string conversion only for debugging, don't try to pass this to 581 * some other execution medium. 582 * 583 * @return string 584 */ 585 public function __toString(): string { 586 return "#Command: {$this->command}"; 587 } 588} 589