Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
33.33% covered (danger)
33.33%
4 / 12
CRAP
71.73% covered (warning)
71.73%
137 / 191
UnboxedExecutor
0.00% covered (danger)
0.00%
0 / 1
33.33% covered (danger)
33.33%
4 / 12
206.53
71.73% covered (warning)
71.73%
137 / 191
 __construct
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 createCommand
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 getTempDirManager
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 7
 setTempDirManager
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setLogger
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 addWrapper
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 addWrappersFromConfiguration
0.00% covered (danger)
0.00%
0 / 1
11.76
61.11% covered (warning)
61.11%
11 / 18
 getPipeDescriptors
0.00% covered (danger)
0.00%
0 / 1
6.00
50.00% covered (danger)
50.00%
5 / 10
 getParentEnvironment
0.00% covered (danger)
0.00%
0 / 1
3.07
80.00% covered (warning)
80.00%
4 / 5
 fixLocaleEnvironment
0.00% covered (danger)
0.00%
0 / 1
5.03
88.89% covered (warning)
88.89%
8 / 9
 execute
0.00% covered (danger)
0.00%
0 / 1
76.56
74.38% covered (warning)
74.38%
90 / 121
 buildFinalCommand
0.00% covered (danger)
0.00%
0 / 1
3.02
87.50% covered (warning)
87.50%
7 / 8
<?php
namespace Shellbox\Command;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shellbox\FileUtils;
use Shellbox\Shellbox;
use Shellbox\ShellboxError;
use Shellbox\TempDirManager;
/**
 * A concrete class for executing UnboxedCommand objects
 */
class UnboxedExecutor {
    /** @var LoggerInterface */
    protected $logger;
    /** @var Wrapper[] */
    private $wrappers = [];
    /** @var string|null */
    private $stdoutPath;
    /** @var string|null */
    private $stderrPath;
    /** @var string|null */
    private $tempDirBase;
    /** @var TempDirManager|null */
    private $tempDirManager;
    /**
     * @param string|null $tempDirBase The parent directory of the temporary
     *   directory to use if the command is run on Windows. For example /tmp.
     *   If this is null, sys_get_temp_dir() will be used. The temporary
     *   directory may also be overridden later using setTempDirManager().
     */
    public function __construct( $tempDirBase = null ) {
        $this->logger = new NullLogger;
        $this->tempDirBase = $tempDirBase;
    }
    /**
     * Create a Command linked to this executor.
     *
     * @return UnboxedCommand
     */
    public function createCommand() {
        return new UnboxedCommand( $this );
    }
    /**
     * Get a TempDirManager with optional lazy initialisation
     *
     * @return TempDirManager
     */
    protected function getTempDirManager() {
        if ( !$this->tempDirManager ) {
            $tempDirBase = $this->tempDirBase;
            if ( $tempDirBase === null ) {
                $tempDirBase = sys_get_temp_dir();
            }
            $this->tempDirManager = new TempDirManager(
                $tempDirBase . '/shellbox-' . Shellbox::getUniqueString()
            );
        }
        return $this->tempDirManager;
    }
    /**
     * Explicitly set a TempDirManager, overriding lazy initialisation config
     *
     * @param TempDirManager $manager
     */
    public function setTempDirManager( TempDirManager $manager ) {
        $this->tempDirManager = $manager;
    }
    /**
     * Set the logger.
     *
     * @param LoggerInterface $logger
     */
    public function setLogger( LoggerInterface $logger ) {
        $this->logger = $logger;
        foreach ( $this->wrappers as $wrapper ) {
            $wrapper->setLogger( $logger );
        }
    }
    /**
     * Add a Wrapper, which modifies the Command, typically providing more
     * security.
     *
     * @param Wrapper $wrapper
     */
    public function addWrapper( Wrapper $wrapper ) {
        $wrapper->setLogger( $this->logger );
        $this->wrappers[] = $wrapper;
    }
    /**
     * Add wrappers based on a configuration array
     *
     * @param array $config Associative array of configuration parameters:
     *   - useSystemd: If true, systemd-run will be used
     *   - useBashWrapper: If true, limit.sh will be used
     *   - useFirejail: If true, firejail will be used
     *   - firejailPath: The path to the firejail binary
     *   - firejailProfile: The path to the firejail profile
     *   - cgroup: A writable cgroup path which can be used for manual memory
     *     limiting
     */
    public function addWrappersFromConfiguration( $config ) {
        $isWindows = PHP_OS_FAMILY === 'Windows';
        $useSystemd = !empty( $config['useSystemd'] );
        if ( isset( $config['useBashWrapper'] ) ) {
            $useBashWrapper = $config['useBashWrapper'];
        } elseif ( $isWindows || $useSystemd ) {
            $useBashWrapper = false;
        } else {
            $useBashWrapper = is_executable( '/bin/bash' );
        }
        if ( $useBashWrapper ) {
            $this->addWrapper( new BashWrapper( $config['cgroup'] ?? '' ) );
        }
        if ( !empty( $config['useFirejail'] ) ) {
            $this->addWrapper( new FirejailWrapper(
                $config['firejailPath'] ?? '/usr/bin/firejail',
                $config['firejailProfile'] ?? __DIR__ . '/firejail.profile'
            ) );
        }
        if ( $useSystemd ) {
            $this->addWrapper( new SystemdWrapper );
        }
        if ( $isWindows ) {
            $this->addWrapper( new WindowsWrapper );
        }
    }
    /**
     * Get pipe descriptors for proc_open()
     *
     * @param Command $command
     * @return array
     */
    private function getPipeDescriptors( Command $command ) {
        $desc = [
            0 => $command->getPassStdin() ? [ 'file', 'php://stdin', 'r' ] : [ 'pipe', 'r' ]
        ];
        if ( PHP_OS_FAMILY === 'Windows' ) {
            // PHP's view of Windows anonymous pipes is too broken to be usable
            $this->stdoutPath = $this->getTempDirManager()->preparePath( 'sb-stdout' );
            $this->stderrPath = $this->getTempDirManager()->preparePath( 'sb-stderr' );
            $desc[1] = [ 'file', $this->stdoutPath, 'wb' ];
            $desc[2] = [ 'file', $this->stderrPath, 'wb' ];
        } else {
            $desc[1] = $desc[2] = [ 'pipe', 'w' ];
        }
        if ( $command->getUseLogPipe() ) {
            $desc[3] = [ 'pipe', 'w' ];
        }
        return $desc;
    }
    /**
     * Get the environment to be passed through to the subprocess. In CLI mode
     * this uses getenv() because that is backwards compatible and relatively
     * sensible. In the other SAPIs, there's no way to get the real environment
     * short of shell_exec('env'), but it's usually near-empty anyway. We add
     * PATH for convenience.
     *
     * In the FastCGI SAPI, $_ENV and getenv() return CGI-like variables sent
     * from the web server. So the PATH here is typically inherited from Apache
     * not PHP-FPM.
     *
     * @return array
     */
    private function getParentEnvironment() {
        if ( PHP_SAPI === 'cli' ) {
            return getenv();
        } elseif ( isset( $_ENV['PATH'] ) ) {
            return [ 'PATH' => $_ENV['PATH'] ];
        } else {
            return [];
        }
    }
    /**
     * If the environment contains an unsafe character set, filter out the
     * variable.
     *
     * @param array &$env
     */
    private function fixLocaleEnvironment( array &$env ) {
        if ( PHP_OS_FAMILY === 'Windows' ) {
            // Probably OK?
            return;
        }
        foreach ( [ 'LC_CTYPE', 'LC_ALL', 'LANG' ] as $name ) {
            if ( isset( $env[$name] )
                && preg_match( '/\.(gb\w*)/i', $env[$name] )
            ) {
                $this->logger->warning( "Filtering out unsafe environment variable " .
                    "$name={$env[$name]}" );
                unset( $env[$name] );
            }
        }
    }
    /**
     * @param Command $command
     * @return UnboxedResult
     * @throws ShellboxError
     */
    public function execute( Command $command ) {
        $command = clone $command;
        $this->buildFinalCommand( $command );
        $cmd = $command->getCommandString();
        $this->logger->info( "Executing: $cmd" );
        // 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 ) > Shellbox::getMaxCmdLength() ) {
            throw new ShellboxError( 'Total length of $cmd must not exceed MAX_ARG_STRLEN' );
        }
        $desc = $this->getPipeDescriptors( $command );
        $cmd = $command->getCommandString();
        $options = $command->getProcOpenOptions();
        $combinedEnvironment = $command->getEnvironment() + $this->getParentEnvironment();
        $this->fixLocaleEnvironment( $combinedEnvironment );
        $env = [];
        foreach ( $combinedEnvironment as $name => $value ) {
            $env[] = "$name=$value";
        }
        $pipes = null;
        $proc = proc_open( $cmd, $desc, $pipes,
            $command->getWorkingDirectory(), $env, $options );
        if ( !$proc ) {
            $this->logger->error( "proc_open() failed: {command}", [ 'command' => $cmd ] );
            throw new ShellboxError( 'proc_open() failed' );
        }
        $buffers = [
            0 => $command->getStdin(), // input
            1 => '', // stdout
            2 => '', // stderr
            3 => '', // log
        ];
        $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]";
        /* The select(2) system call only guarantees a "sufficiently small write"
         * can be made without blocking. And on Linux the read might block too
         * in certain cases, although I don't know if any of them can occur here.
         * Regardless, set all the pipes to non-blocking to avoid T184171.
         */
        foreach ( $pipes as $pipe ) {
            stream_set_blocking( $pipe, false );
        }
        $running = true;
        $timeout = null;
        $numReadyPipes = 0;
        while ( $pipes && ( $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;
                }
            }
            error_clear_last();
            $readPipes = array_filter( $pipes, static function ( $fd ) use ( $desc ) {
                return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'r';
            }, ARRAY_FILTER_USE_KEY );
            $writePipes = array_filter( $pipes, static function ( $fd ) use ( $desc ) {
                return $desc[$fd][0] === 'pipe' && $desc[$fd][1] === 'w';
            }, ARRAY_FILTER_USE_KEY );
            // stream_select parameter names are from the POV of us being able to do the operation;
            // proc_open descriptor types are from the POV of the process doing it.
            // So $writePipes is passed as the $read parameter and $readPipes as $write.
            // phpcs:ignore Generic.PHP.NoSilencedErrors
            $numReadyPipes = @stream_select( $writePipes, $readPipes, $emptyArray, $timeout );
            if ( $numReadyPipes === false ) {
                $error = error_get_last();
                if ( !$error ) {
                    $logMsg = 'unknown error';
                    break;
                } elseif ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) {
                    continue;
                } else {
                    $logMsg = $error['message'];
                    break;
                }
            }
            foreach ( $writePipes + $readPipes as $fd => $pipe ) {
                // True if a pipe is unblocked for us to write into, false if for reading from
                $isWrite = array_key_exists( $fd, $readPipes );
                if ( $isWrite ) {
                    // Don't bother writing if the buffer is empty
                    if ( $buffers[$fd] === '' ) {
                        fclose( $pipes[$fd] );
                        unset( $pipes[$fd] );
                        continue;
                    }
                    $res = fwrite( $pipe, $buffers[$fd], 65536 );
                } else {
                    $res = fread( $pipe, 65536 );
                }
                if ( $res === false ) {
                    $logMsg = 'Error ' . ( $isWrite ? 'writing to' : 'reading from' ) . ' pipe';
                    break 2;
                }
                if ( $res === '' || $res === 0 ) {
                    // End of file?
                    if ( feof( $pipe ) ) {
                        fclose( $pipes[$fd] );
                        unset( $pipes[$fd] );
                    }
                } elseif ( $isWrite ) {
                    $buffers[$fd] = (string)substr( $buffers[$fd], $res );
                    if ( $buffers[$fd] === '' ) {
                        fclose( $pipes[$fd] );
                        unset( $pipes[$fd] );
                    }
                } else {
                    $buffers[$fd] .= $res;
                    if ( $fd === 3 && strpos( $res, "\n" ) !== false ) {
                        // For the log FD, every line is a separate log entry.
                        $lines = explode( "\n", $buffers[3] );
                        $buffers[3] = array_pop( $lines );
                        foreach ( $lines as $line ) {
                            $this->logger->info( $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']}";
            // Use the shell convention of setting the exit status to 128 + the signal number
            $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 ) {
                // Per the shell convention
                $logMsg = "Probably exited with signal " . ( $retval - 128 );
            }
        }
        if ( $logMsg !== false ) {
            $this->logger->warning( "$logMsg: {command}", [ 'command' => $cmd ] );
        }
        if ( $this->stdoutPath !== null ) {
            $stdout = FileUtils::getContents( $this->stdoutPath );
        } else {
            $stdout = $buffers[1];
        }
        if ( $this->stderrPath !== null ) {
            $stderr = FileUtils::getContents( $this->stderrPath );
        } else {
            $stderr = $buffers[2];
        }
        if ( $stderr !== '' && $command->getForwardStderr() ) {
            fwrite( STDERR, $stderr );
        }
        if ( $stderr !== '' && $command->getLogStderr() ) {
            $this->logger->error( "Error running {command}: {error}", [
                'command' => $cmd,
                'error' => $stderr,
                'exitcode' => $retval,
                'exception' => new ShellboxError( 'Shell error' ),
            ] );
        }
        return ( new UnboxedResult )
            ->exitCode( $retval )
            ->stdout( $stdout )
            ->stderr( $stderr );
    }
    /**
     * Modify the command by running wrappers on it.
     *
     * @param Command $command
     */
    protected function buildFinalCommand( Command $command ) {
        $wrappers = $this->wrappers;
        usort( $wrappers, static function ( Wrapper $a, Wrapper $b ) {
            return $a->getPriority() <=> $b->getPriority();
        } );
        foreach ( $wrappers as $wrapper ) {
            $wrapper->wrap( $command );
        }
        if ( $command->getIncludeStderr() ) {
            $command->unsafeCommand( $command->getCommandString() . ' 2>&1' );
        }
    }
}