Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.38% covered (danger)
15.38%
10 / 65
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommandFactory
15.38% covered (danger)
15.38%
10 / 65
0.00% covered (danger)
0.00%
0 / 6
214.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 findFirejail
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 logStderr
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalShellboxOptions
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 create
45.45% covered (danger)
45.45%
10 / 22
0.00% covered (danger)
0.00%
0 / 1
11.84
 createBoxed
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Shell;
8
9use ExecutableFinder;
10use Psr\Log\LoggerAwareTrait;
11use Psr\Log\NullLogger;
12use Shellbox\Command\BoxedCommand;
13use Shellbox\Command\RemoteBoxedExecutor;
14use Shellbox\Shellbox;
15
16/**
17 * Factory facilitating dependency injection for Command
18 *
19 * @since 1.30
20 */
21class CommandFactory {
22    use LoggerAwareTrait;
23
24    /** @var array */
25    private $limits;
26
27    /** @var string|bool */
28    private $cgroup;
29
30    /** @var bool */
31    private $doLogStderr = false;
32
33    /**
34     * @var string|bool
35     */
36    private $restrictionMethod;
37
38    /**
39     * @var string|bool|null
40     */
41    private $firejail;
42
43    /** @var bool */
44    private $useAllUsers;
45
46    /** @var ShellboxClientFactory */
47    private $shellboxClientFactory;
48
49    /**
50     * @param ShellboxClientFactory $shellboxClientFactory
51     * @param array $limits See {@see Command::limits()}
52     * @param string|bool $cgroup
53     * @param string|bool $restrictionMethod
54     */
55    public function __construct( ShellboxClientFactory $shellboxClientFactory,
56        array $limits, $cgroup, $restrictionMethod
57    ) {
58        $this->shellboxClientFactory = $shellboxClientFactory;
59        $this->limits = $limits;
60        $this->cgroup = $cgroup;
61        if ( $restrictionMethod === 'autodetect' ) {
62            // On Linux systems check for firejail
63            if ( PHP_OS === 'Linux' && $this->findFirejail() ) {
64                $this->restrictionMethod = 'firejail';
65            } else {
66                $this->restrictionMethod = false;
67            }
68        } else {
69            $this->restrictionMethod = $restrictionMethod;
70        }
71        $this->setLogger( new NullLogger() );
72    }
73
74    /**
75     * @return bool|string
76     */
77    protected function findFirejail() {
78        if ( $this->firejail === null ) {
79            $this->firejail = ExecutableFinder::findInDefaultPaths( 'firejail' );
80        }
81
82        return $this->firejail;
83    }
84
85    /**
86     * When enabled, text sent to stderr will be logged with a level of 'error'.
87     *
88     * @param bool $yesno
89     * @see Command::logStderr
90     */
91    public function logStderr( bool $yesno = true ): void {
92        $this->doLogStderr = $yesno;
93    }
94
95    /**
96     * Get the options which will be used for local unboxed execution.
97     * Shellbox should be configured to act in an approximately backwards
98     * compatible way, equivalent to the pre-Shellbox MediaWiki shell classes.
99     *
100     * @return array
101     */
102    private function getLocalShellboxOptions() {
103        $options = [
104            'tempDir' => wfTempDir(),
105            'useBashWrapper' => file_exists( '/bin/bash' ),
106            'cgroup' => $this->cgroup
107        ];
108        if ( $this->restrictionMethod === 'firejail' ) {
109            $firejailPath = $this->findFirejail();
110            if ( !$firejailPath ) {
111                throw new \RuntimeException( 'firejail is enabled, but cannot be found' );
112            }
113            $options['useFirejail'] = true;
114            $options['firejailPath'] = $firejailPath;
115            $options['firejailProfile'] = __DIR__ . '/firejail.profile';
116        }
117        return $options;
118    }
119
120    /**
121     * Instantiates a new Command
122     */
123    public function create(): Command {
124        $allUsers = false;
125        if ( $this->restrictionMethod === 'firejail' ) {
126            if ( $this->useAllUsers === null ) {
127                global $IP;
128                // In case people are doing funny things with symlinks
129                // or relative paths, resolve them all.
130                $realIP = realpath( $IP );
131                $currentUser = posix_getpwuid( posix_geteuid() );
132                $this->useAllUsers = str_starts_with( $realIP, '/home/' )
133                    && !str_starts_with( $realIP, $currentUser['dir'] );
134                if ( $this->useAllUsers ) {
135                    $this->logger->warning( 'firejail: MediaWiki is located ' .
136                        'in a home directory that does not belong to the ' .
137                        'current user, so allowing access to all home ' .
138                        'directories (--allusers)' );
139                }
140            }
141            $allUsers = $this->useAllUsers;
142        }
143        $executor = Shellbox::createUnboxedExecutor(
144            $this->getLocalShellboxOptions(), $this->logger );
145
146        $command = new Command( $executor );
147        $command->setLogger( $this->logger );
148        if ( $allUsers ) {
149            $command->allowPath( '/home' );
150        }
151        return $command
152            ->limits( $this->limits )
153            ->logStderr( $this->doLogStderr );
154    }
155
156    /**
157     * Instantiates a new BoxedCommand.
158     *
159     * @since 1.36
160     * @param ?string $service Name of Shellbox (as configured in
161     *                         $wgShellboxUrls) that should be used
162     * @param int|float|null $wallTimeLimit The wall time limit, or null to use the default.
163     *   This needs to be set early so that the HTTP timeout is configured correctly.
164     * @return BoxedCommand
165     */
166    public function createBoxed( ?string $service = null, $wallTimeLimit = null ): BoxedCommand {
167        $wallTimeLimit ??= $this->limits['walltime'];
168        if ( $this->shellboxClientFactory->isEnabled( $service ) ) {
169            $client = $this->shellboxClientFactory->getClient( [
170                'timeout' => $wallTimeLimit + 1,
171                'service' => $service,
172            ] );
173            $executor = new RemoteBoxedExecutor( $client );
174            $executor->setLogger( $this->logger );
175        } else {
176            $executor = Shellbox::createBoxedExecutor(
177                $this->getLocalShellboxOptions(),
178                $this->logger );
179        }
180        return $executor->createCommand()
181            ->cpuTimeLimit( $this->limits['time'] )
182            ->wallTimeLimit( $wallTimeLimit )
183            ->memoryLimit( $this->limits['memory'] * 1024 )
184            ->fileSizeLimit( $this->limits['filesize'] * 1024 )
185            ->logStderr( $this->doLogStderr );
186    }
187}