<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

namespace MediaWiki\Shell;

use ExecutableFinder;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Shellbox\Command\BoxedCommand;
use Shellbox\Command\RemoteBoxedExecutor;
use Shellbox\Shellbox;

/**
 * Factory facilitating dependency injection for Command
 *
 * @since 1.30
 */
class CommandFactory {
	use LoggerAwareTrait;

	/** @var array */
	private $limits;

	/** @var string|bool */
	private $cgroup;

	/** @var bool */
	private $doLogStderr = false;

	/**
	 * @var string|bool
	 */
	private $restrictionMethod;

	/**
	 * @var string|bool|null
	 */
	private $firejail;

	/** @var bool */
	private $useAllUsers;

	/** @var ShellboxClientFactory */
	private $shellboxClientFactory;

	/**
	 * @param ShellboxClientFactory $shellboxClientFactory
	 * @param array $limits See {@see Command::limits()}
	 * @param string|bool $cgroup
	 * @param string|bool $restrictionMethod
	 */
	public function __construct( ShellboxClientFactory $shellboxClientFactory,
		array $limits, $cgroup, $restrictionMethod
	) {
		$this->shellboxClientFactory = $shellboxClientFactory;
		$this->limits = $limits;
		$this->cgroup = $cgroup;
		if ( $restrictionMethod === 'autodetect' ) {
			// On Linux systems check for firejail
			if ( PHP_OS === 'Linux' && $this->findFirejail() ) {
				$this->restrictionMethod = 'firejail';
			} else {
				$this->restrictionMethod = false;
			}
		} else {
			$this->restrictionMethod = $restrictionMethod;
		}
		$this->setLogger( new NullLogger() );
	}

	/**
	 * @return bool|string
	 */
	protected function findFirejail() {
		if ( $this->firejail === null ) {
			$this->firejail = ExecutableFinder::findInDefaultPaths( 'firejail' );
		}

		return $this->firejail;
	}

	/**
	 * When enabled, text sent to stderr will be logged with a level of 'error'.
	 *
	 * @param bool $yesno
	 * @see Command::logStderr
	 */
	public function logStderr( bool $yesno = true ): void {
		$this->doLogStderr = $yesno;
	}

	/**
	 * Get the options which will be used for local unboxed execution.
	 * Shellbox should be configured to act in an approximately backwards
	 * compatible way, equivalent to the pre-Shellbox MediaWiki shell classes.
	 *
	 * @return array
	 */
	private function getLocalShellboxOptions() {
		$options = [
			'tempDir' => wfTempDir(),
			'useBashWrapper' => file_exists( '/bin/bash' ),
			'cgroup' => $this->cgroup
		];
		if ( $this->restrictionMethod === 'firejail' ) {
			$firejailPath = $this->findFirejail();
			if ( !$firejailPath ) {
				throw new \RuntimeException( 'firejail is enabled, but cannot be found' );
			}
			$options['useFirejail'] = true;
			$options['firejailPath'] = $firejailPath;
			$options['firejailProfile'] = __DIR__ . '/firejail.profile';
		}
		return $options;
	}

	/**
	 * Instantiates a new Command
	 */
	public function create(): Command {
		$allUsers = false;
		if ( $this->restrictionMethod === 'firejail' ) {
			if ( $this->useAllUsers === null ) {
				global $IP;
				// In case people are doing funny things with symlinks
				// or relative paths, resolve them all.
				$realIP = realpath( $IP );
				$currentUser = posix_getpwuid( posix_geteuid() );
				$this->useAllUsers = str_starts_with( $realIP, '/home/' )
					&& !str_starts_with( $realIP, $currentUser['dir'] );
				if ( $this->useAllUsers ) {
					$this->logger->warning( 'firejail: MediaWiki is located ' .
						'in a home directory that does not belong to the ' .
						'current user, so allowing access to all home ' .
						'directories (--allusers)' );
				}
			}
			$allUsers = $this->useAllUsers;
		}
		$executor = Shellbox::createUnboxedExecutor(
			$this->getLocalShellboxOptions(), $this->logger );

		$command = new Command( $executor );
		$command->setLogger( $this->logger );
		if ( $allUsers ) {
			$command->allowPath( '/home' );
		}
		return $command
			->limits( $this->limits )
			->logStderr( $this->doLogStderr );
	}

	/**
	 * Instantiates a new BoxedCommand.
	 *
	 * @since 1.36
	 * @param ?string $service Name of Shellbox (as configured in
	 *                         $wgShellboxUrls) that should be used
	 * @param int|float|null $wallTimeLimit The wall time limit, or null to use the default.
	 *   This needs to be set early so that the HTTP timeout is configured correctly.
	 * @return BoxedCommand
	 */
	public function createBoxed( ?string $service = null, $wallTimeLimit = null ): BoxedCommand {
		$wallTimeLimit ??= $this->limits['walltime'];
		if ( $this->shellboxClientFactory->isEnabled( $service ) ) {
			$client = $this->shellboxClientFactory->getClient( [
				'timeout' => $wallTimeLimit + 1,
				'service' => $service,
			] );
			$executor = new RemoteBoxedExecutor( $client );
			$executor->setLogger( $this->logger );
		} else {
			$executor = Shellbox::createBoxedExecutor(
				$this->getLocalShellboxOptions(),
				$this->logger );
		}
		return $executor->createCommand()
			->cpuTimeLimit( $this->limits['time'] )
			->wallTimeLimit( $wallTimeLimit )
			->memoryLimit( $this->limits['memory'] * 1024 )
			->fileSizeLimit( $this->limits['filesize'] * 1024 )
			->logStderr( $this->doLogStderr );
	}
}
