Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.58% covered (warning)
73.58%
39 / 53
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Command
73.58% covered (warning)
73.58%
39 / 53
30.00% covered (danger)
30.00%
3 / 10
34.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 __destruct
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 setLogger
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 limits
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 profileMethod
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 input
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 restrict
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 whitelistPaths
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 execute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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\ShellDisabledError;
25use Profiler;
26use Psr\Log\LoggerInterface;
27use Psr\Log\NullLogger;
28use Shellbox\Command\UnboxedCommand;
29use Shellbox\Command\UnboxedExecutor;
30use Shellbox\Command\UnboxedResult;
31use Stringable;
32use Wikimedia\ScopedCallback;
33
34/**
35 * Class used for executing shell commands
36 *
37 * @since 1.30
38 */
39class Command extends UnboxedCommand implements Stringable {
40    private bool $everExecuted = false;
41
42    /** @var string */
43    private $method;
44
45    protected LoggerInterface $logger;
46
47    /**
48     * Don't call directly, instead use Shell::command()
49     *
50     * @param UnboxedExecutor $executor
51     * @throws ShellDisabledError
52     */
53    public function __construct( UnboxedExecutor $executor ) {
54        if ( Shell::isDisabled() ) {
55            throw new ShellDisabledError();
56        }
57        parent::__construct( $executor );
58        $this->setLogger( new NullLogger() );
59    }
60
61    /**
62     * Makes sure the programmer didn't forget to execute the command after all
63     */
64    public function __destruct() {
65        if ( !$this->everExecuted ) {
66            $context = [ 'command' => $this->getCommandString() ];
67            $message = __CLASS__ . " was instantiated, but execute() was never called.";
68            if ( $this->method ) {
69                $message .= ' Calling method: {method}.';
70                $context['method'] = $this->method;
71            }
72            $message .= ' Command: {command}';
73            $this->logger->warning( $message, $context );
74        }
75    }
76
77    public function setLogger( LoggerInterface $logger ) {
78        $this->logger = $logger;
79        if ( $this->executor ) {
80            $this->executor->setLogger( $logger );
81        }
82    }
83
84    /**
85     * Sets execution limits
86     *
87     * @param array $limits Associative array of limits. Keys (all optional):
88     *   filesize (for ulimit -f), memory, time, walltime.
89     * @return $this
90     */
91    public function limits( array $limits ): Command {
92        if ( !isset( $limits['walltime'] ) && isset( $limits['time'] ) ) {
93            // Emulate the behavior of old wfShellExec() where walltime fell back on time
94            // if the latter was overridden and the former wasn't
95            $limits['walltime'] = $limits['time'];
96        }
97        if ( isset( $limits['filesize'] ) ) {
98            $this->fileSizeLimit( $limits['filesize'] * 1024 );
99        }
100        if ( isset( $limits['memory'] ) ) {
101            $this->memoryLimit( $limits['memory'] * 1024 );
102        }
103        if ( isset( $limits['time'] ) ) {
104            $this->cpuTimeLimit( $limits['time'] );
105        }
106        if ( isset( $limits['walltime'] ) ) {
107            $this->wallTimeLimit( $limits['walltime'] );
108        }
109
110        return $this;
111    }
112
113    /**
114     * Sets calling function for profiler. By default, the caller for execute() will be used.
115     *
116     * @param string $method
117     * @return $this
118     */
119    public function profileMethod( string $method ): Command {
120        $this->method = $method;
121
122        return $this;
123    }
124
125    /**
126     * Sends the provided input to the command. Defaults to an empty string.
127     * If you want to pass stdin through to the command instead, use
128     * passStdin().
129     *
130     * @param string $inputString
131     * @return $this
132     */
133    public function input( string $inputString ): Command {
134        return $this->stdin( $inputString );
135    }
136
137    /**
138     * Set restrictions for this request, overwriting any previously set restrictions.
139     *
140     * Add the "no network" restriction:
141     * @code
142     *     $command->restrict( Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK );
143     * @endcode
144     *
145     * Allow LocalSettings.php access:
146     * @code
147     *     $command->restrict( Shell::RESTRICT_DEFAULT & ~Shell::NO_LOCALSETTINGS );
148     * @endcode
149     *
150     * Disable all restrictions:
151     * @code
152     *  $command->restrict( Shell::RESTRICT_NONE );
153     * @endcode
154     *
155     * @deprecated since 1.36 Set the options using their separate accessors
156     *
157     * @since 1.31
158     * @param int $restrictions
159     * @return $this
160     */
161    public function restrict( int $restrictions ): Command {
162        $this->privateUserNamespace( (bool)( $restrictions & Shell::NO_ROOT ) );
163        $this->firejailDefaultSeccomp( (bool)( $restrictions & Shell::SECCOMP ) );
164        $this->noNewPrivs( (bool)( $restrictions & Shell::SECCOMP ) );
165        $this->privateDev( (bool)( $restrictions & Shell::PRIVATE_DEV ) );
166        $this->disableNetwork( (bool)( $restrictions & Shell::NO_NETWORK ) );
167        if ( $restrictions & Shell::NO_EXECVE ) {
168            $this->disabledSyscalls( [ 'execve' ] );
169        } else {
170            $this->disabledSyscalls( [] );
171        }
172        if ( $restrictions & Shell::NO_LOCALSETTINGS ) {
173            $this->disallowedPaths( [ realpath( MW_CONFIG_FILE ) ] );
174        } else {
175            $this->disallowedPaths( [] );
176        }
177        if ( $restrictions === 0 ) {
178            $this->disableSandbox();
179        }
180
181        return $this;
182    }
183
184    /**
185     * If called, only the files/directories that are
186     * whitelisted will be available to the shell command.
187     *
188     * limit.sh will always be whitelisted
189     *
190     * @deprecated since 1.36 Use allowPath/disallowPath. Hard
191     *   deprecated in 1.40 and to be removed in 1.41
192     * @param string[] $paths
193     * @return $this
194     */
195    public function whitelistPaths( array $paths ): Command {
196        wfDeprecated( __METHOD__, '1.36' );
197        $this->allowedPaths( array_merge( $this->getAllowedPaths(), $paths ) );
198        return $this;
199    }
200
201    /**
202     * Executes command. Afterwards, getExitCode() and getOutput() can be used to access execution
203     * results.
204     *
205     * @return UnboxedResult
206     * @throws Exception
207     */
208    public function execute(): UnboxedResult {
209        $this->everExecuted = true;
210        $profileMethod = $this->method ?: wfGetCaller();
211        $scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod );
212        $result = parent::execute();
213        ScopedCallback::consume( $scoped );
214        return $result;
215    }
216
217    /**
218     * Returns the final command line before environment/limiting, etc are applied.
219     * Use string conversion only for debugging, don't try to pass this to
220     * some other execution medium.
221     *
222     * @return string
223     */
224    public function __toString(): string {
225        return '#Command: ' . $this->getCommandString();
226    }
227}