diff --git a/src/Action/Online/RunExercise.php b/src/Action/Online/RunExercise.php index 159cb635..840299fa 100644 --- a/src/Action/Online/RunExercise.php +++ b/src/Action/Online/RunExercise.php @@ -8,11 +8,15 @@ use PhpSchool\PhpWorkshop\Event\CliExecuteEvent; use PhpSchool\PhpWorkshop\Event\Event; use PhpSchool\PhpWorkshop\Exception\CouldNotRunException; +use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface; use PhpSchool\PhpWorkshop\Input\Input; use PhpSchool\PhpWorkshop\Output\BufferedOutput; +use PhpSchool\PhpWorkshop\Solution\DirectorySolution; use PhpSchool\Website\Action\JsonUtils; +use PhpSchool\Website\Online\CloudInstalledWorkshop; use PhpSchool\Website\Online\CloudWorkshopRepository; use PhpSchool\Website\Online\ProjectUploader; +use PhpSchool\Website\Online\Sandbox\SandboxedCliRunner; use PhpSchool\Website\Online\StudentWorkshopState; use PhpSchool\Website\User\SessionStorageInterface; use PhpSchool\Website\User\StudentDTO; @@ -61,6 +65,20 @@ public function __invoke( return $this->withJson(['success' => false, 'error' => $e->getMessage()], $response); } + try { + return $this->runProject($workshop, $exercise, $project, $response); + } finally { + $this->runInfo = []; + (new Filesystem())->remove($project->getBaseDirectory()); + } + } + + private function runProject( + CloudInstalledWorkshop $workshop, + ExerciseInterface $exercise, + DirectorySolution $project, + Response $response + ): Response { $output = new BufferedOutput(); $workshop->getExerciseDispatcher() @@ -69,11 +87,13 @@ public function __invoke( $this->collectRunInfo($event, $output); }); - try { $result = $workshop->getExerciseDispatcher()->run( $exercise, - new Input($workshop->getCode(), ['program' => $project->getEntryPoint()->getAbsolutePath()]), + new Input($workshop->getCode(), [ + 'program' => $project->getEntryPoint()->getAbsolutePath(), + SandboxedCliRunner::ROOT_ARGUMENT => $project->getBaseDirectory(), + ]), $output ); } catch (CouldNotRunException $e) { @@ -83,16 +103,10 @@ public function __invoke( ], $response); } - $data = [ + return $this->withJson([ 'runs' => $this->runInfo, 'success' => $result - ]; - - $this->runInfo = []; - - (new Filesystem())->remove($project->getBaseDirectory()); - - return $this->withJson($data, $response); + ], $response); } private function getStudent(): StudentDTO diff --git a/src/Action/Online/VerifyExercise.php b/src/Action/Online/VerifyExercise.php index 6e258fc0..83cec512 100644 --- a/src/Action/Online/VerifyExercise.php +++ b/src/Action/Online/VerifyExercise.php @@ -8,6 +8,7 @@ use PhpSchool\Website\Action\JsonUtils; use PhpSchool\Website\Online\CloudWorkshopRepository; use PhpSchool\Website\Online\ProjectUploader; +use PhpSchool\Website\Online\Sandbox\SandboxedCliRunner; use PhpSchool\Website\Online\StudentWorkshopState; use PhpSchool\Website\Online\VueResultsRenderer; use PhpSchool\Website\User\SessionStorageInterface; @@ -49,10 +50,17 @@ public function __invoke( return $this->withJson(['success' => false, 'error' => $e->getMessage()], $response); } - $results = $workshop->getExerciseDispatcher()->verify( - $exercise, - new Input($workshop->getCode(), ['program' => $project->getEntryPoint()->getAbsolutePath()]), - ); + try { + $results = $workshop->getExerciseDispatcher()->verify( + $exercise, + new Input($workshop->getCode(), [ + 'program' => $project->getEntryPoint()->getAbsolutePath(), + SandboxedCliRunner::ROOT_ARGUMENT => $project->getBaseDirectory(), + ]), + ); + } finally { + (new Filesystem())->remove($project->getBaseDirectory()); + } $data = [ 'results' => $this->resultsRenderer->render($results, $exercise), @@ -66,9 +74,6 @@ public function __invoke( ); } - //clean up - (new Filesystem())->remove($project->getBaseDirectory()); - return $this->withJson($data, $response); } diff --git a/src/Online/CloudInstalledWorkshop.php b/src/Online/CloudInstalledWorkshop.php index 9d33dbd2..a3b6aab8 100644 --- a/src/Online/CloudInstalledWorkshop.php +++ b/src/Online/CloudInstalledWorkshop.php @@ -9,12 +9,16 @@ use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface; use PhpSchool\PhpWorkshop\ExerciseDispatcher; use PhpSchool\PhpWorkshop\ExerciseRepository; +use PhpSchool\PhpWorkshop\ExerciseRunner\Factory\CustomVerifyingRunnerFactory; +use PhpSchool\PhpWorkshop\ExerciseRunner\RunnerManager; use PhpSchool\PhpWorkshop\Listener\InitialCodeListener; use PhpSchool\PhpWorkshop\Listener\OutputRunInfoListener; use PhpSchool\PhpWorkshop\Logger\Logger; use PhpSchool\PhpWorkshop\Output\BufferedOutput; use PhpSchool\PhpWorkshop\Output\OutputInterface; use PhpSchool\Website\Entity\Workshop; +use PhpSchool\Website\Online\Sandbox\PhpSandbox; +use PhpSchool\Website\Online\Sandbox\SandboxedRunnerFactory; use Psr\Log\LoggerInterface; use RuntimeException; use Symfony\Component\String\Slugger\AsciiSlugger; @@ -25,7 +29,7 @@ class CloudInstalledWorkshop implements \JsonSerializable private Workshop $workshop; private AsciiSlugger $slugger; - public function __construct(Application $application, Workshop $workshop) + public function __construct(Application $application, Workshop $workshop, PhpSandbox $sandbox = new PhpSandbox()) { /** @var \DI\Container $container */ $container = $application->configure(); @@ -34,6 +38,7 @@ public function __construct(Application $application, Workshop $workshop) $this->slugger = new AsciiSlugger(); $this->configureLogger(); + $this->configureRunners($sandbox); $this->configureEvents(); } @@ -124,6 +129,18 @@ private function configureLogger(): void $this->container->set(OutputInterface::class, new BufferedOutput()); } + private function configureRunners(PhpSandbox $sandbox): void + { + /** @var EventDispatcher $eventDispatcher */ + $eventDispatcher = $this->container->get(EventDispatcher::class); + + $manager = new RunnerManager(); + $manager->addFactory(new SandboxedRunnerFactory($eventDispatcher, $sandbox)); + $manager->addFactory(new CustomVerifyingRunnerFactory()); + + $this->container->set(RunnerManager::class, $manager); + } + private function configureEvents(): void { /** @var EventDispatcher $eventDispatch */ diff --git a/src/Online/ComposerPackageAllowlist.php b/src/Online/ComposerPackageAllowlist.php new file mode 100644 index 00000000..4e4efaa1 --- /dev/null +++ b/src/Online/ComposerPackageAllowlist.php @@ -0,0 +1,72 @@ +=!|,@ -]{1,64}$#'; + + /** + * @var array|null + */ + private ?array $packages = null; + + public function __construct( + private readonly string $packageListPath = self::DEFAULT_PACKAGE_LIST, + private readonly int $maxPackages = 10, + ) {} + + /** + * @return array + */ + public function validate(mixed $dependencies): array + { + if (!is_array($dependencies)) { + throw new \RuntimeException('Invalid composer dependencies'); + } + + if (count($dependencies) > $this->maxPackages) { + throw new \RuntimeException(sprintf('A maximum of %d composer dependencies are allowed', $this->maxPackages)); + } + + $require = []; + foreach ($dependencies as $dependency) { + if (!is_array($dependency) || !is_string($dependency['name'] ?? null) || !is_string($dependency['version'] ?? null)) { + throw new \RuntimeException('Invalid composer dependencies'); + } + + $name = $dependency['name']; + $version = $dependency['version']; + + if (1 !== preg_match(self::NAME_PATTERN, $name) || !$this->isKnownPackage($name)) { + throw new \RuntimeException(sprintf('Composer package "%s" is not allowed', $name)); + } + + if (1 !== preg_match(self::VERSION_PATTERN, $version)) { + throw new \RuntimeException(sprintf('Invalid version constraint for composer package "%s"', $name)); + } + + $require[$name] = $version; + } + + return $require; + } + + private function isKnownPackage(string $name): bool + { + if ($this->packages === null) { + $contents = is_readable($this->packageListPath) ? file_get_contents($this->packageListPath) : false; + $list = $contents !== false ? json_decode($contents, true) : null; + $names = is_array($list) && is_array($list['packageNames'] ?? null) ? $list['packageNames'] : []; + + $this->packages = array_flip(array_filter($names, 'is_string')); + } + + return isset($this->packages[$name]); + } +} diff --git a/src/Online/ProjectUploader.php b/src/Online/ProjectUploader.php index 9b802575..98b4943c 100644 --- a/src/Online/ProjectUploader.php +++ b/src/Online/ProjectUploader.php @@ -9,11 +9,17 @@ use PhpSchool\Website\User\StudentDTO; use Psr\Http\Message\ServerRequestInterface as Request; use Symfony\Component\Filesystem\Filesystem; +use PhpSchool\Website\Online\Sandbox\PhpSandbox; +use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; class ProjectUploader { - public function __construct(private readonly PathGenerator $pathGenerator) {} + public function __construct( + private readonly PathGenerator $pathGenerator, + private readonly ComposerPackageAllowlist $composerPackageAllowlist = new ComposerPackageAllowlist(), + private readonly PhpSandbox $sandbox = new PhpSandbox(), + ) {} public function upload(Request $request, StudentDTO $student): DirectorySolution { @@ -23,46 +29,86 @@ public function upload(Request $request, StudentDTO $student): DirectorySolution throw new \RuntimeException('No files were uploaded'); } + $require = []; + if (!empty($data['composer_deps'])) { + $require = $this->composerPackageAllowlist->validate($data['composer_deps']); + } + $basePath = $this->pathGenerator->random($student); if (file_exists($basePath)) { throw new \RuntimeException('Temporary directory already exists'); } + try { $this->writeScripts($data['scripts'], $basePath); + + if (count($require) > 0) { + $this->installDependencies($require, $basePath); + } } catch (\RuntimeException $e) { (new Filesystem())->remove($basePath); throw $e; } - if (isset($data['composer_deps']) && count($data['composer_deps']) > 0) { - $composer = [ - 'require' => array_combine( - array_column($data['composer_deps'], 'name'), - array_column($data['composer_deps'], 'version'), - ) - ]; + $entryPoint = 'solution.php'; + if (isset($data['entry_point']) && is_string($data['entry_point']) && isset($data['scripts'][$data['entry_point']])) { + $entryPoint = $data['entry_point']; + } - file_put_contents(Path::join($basePath, 'composer.json'), json_encode($composer)); + return new DirectorySolution($basePath, $entryPoint, ['composer.lock', 'vendor']); + } - $process = (new Process(['composer', 'update', '--no-interaction'], $basePath, ['XDEBUG_MODE' => 'off'])); - $process->run(); + /** + * @param array $require + */ + private function installDependencies(array $require, string $basePath): void + { + $composer = [ + 'require' => $require, + 'config' => ['allow-plugins' => false], + ]; + + file_put_contents(Path::join($basePath, 'composer.json'), json_encode($composer)); + + $env = ['XDEBUG_MODE' => 'off', 'COMPOSER_NO_INTERACTION' => '1']; + foreach (['PATH', 'HOME', 'COMPOSER_HOME', 'COMPOSER_CACHE_DIR'] as $name) { + $value = getenv($name); + if (is_string($value) && $value !== '') { + $env[$name] = $value; + } } - $entryPoint = 'solution.php'; - if (isset($data['entry_point']) && isset($data['scripts'][$data['entry_point']])) { - $entryPoint = $data['entry_point']; + $process = new Process( + ['composer', 'update', '--no-interaction', '--no-plugins', '--no-scripts', '--no-progress'], + $basePath, + $this->sandbox->environment($env), + null, + 120 + ); + + try { + $process->run(); + } catch (ProcessTimedOutException) { + throw new \RuntimeException('Composer dependencies could not be installed'); } - return new DirectorySolution($basePath, $entryPoint, ['composer.lock', 'vendor']); + if (!$process->isSuccessful()) { + throw new \RuntimeException('Composer dependencies could not be installed'); + } } /** - * @param array $scripts + * @param array $scripts */ private function writeScripts(array $scripts, string $basePath): void { foreach ($scripts as $filePath => $content) { + if (!is_string($content)) { + throw new \RuntimeException('Invalid file contents'); + } + + $filePath = (string) $filePath; $this->validatePath($filePath); $fileName = basename($filePath); diff --git a/src/Online/Sandbox/PhpSandbox.php b/src/Online/Sandbox/PhpSandbox.php new file mode 100644 index 00000000..dc6d6b1c --- /dev/null +++ b/src/Online/Sandbox/PhpSandbox.php @@ -0,0 +1,148 @@ + $readablePaths + * @return list + */ + public function iniArguments(string $rootDirectory, array $readablePaths = []): array + { + $settings = [ + 'open_basedir' => implode(PATH_SEPARATOR, array_unique([$rootDirectory, ...$readablePaths])), + 'disable_functions' => implode(',', self::DISABLED_FUNCTIONS), + 'memory_limit' => $this->memoryLimit, + 'max_execution_time' => (string) $this->maxExecutionTime, + 'allow_url_fopen' => '0', + 'allow_url_include' => '0', + 'enable_dl' => '0', + 'ffi.enable' => '0', + 'auto_prepend_file' => '', + 'auto_append_file' => '', + 'sys_temp_dir' => $rootDirectory, + 'upload_tmp_dir' => $rootDirectory, + 'session.save_path' => $rootDirectory, + 'expose_php' => '0', + ]; + + $arguments = []; + foreach ($settings as $name => $value) { + $arguments[] = '-d'; + $arguments[] = sprintf('%s=%s', $name, $value); + } + + return $arguments; + } + + /** + * @param array $variables + * @return array + */ + public function environment(array $variables = []): array + { + $inherited = array_merge( + array_keys(getenv()), + array_keys($_ENV), + array_keys(array_filter($_SERVER, 'is_string')), + ); + + $cleared = []; + foreach ($inherited as $name) { + $cleared[(string) $name] = false; + } + + return $variables + $cleared; + } + + /** + * @param array $arguments + * @return list + */ + public function readablePathsFromArguments(array $arguments): array + { + $tempDir = rtrim(System::tempDir(), '/') . '/'; + + $paths = []; + foreach ($arguments as $argument) { + $candidate = (string) preg_replace('/^sqlite:/', '', $argument); + + if (!str_starts_with($candidate, '/')) { + continue; + } + + $realPath = realpath($candidate); + + if ($realPath === false) { + $realPath = realpath(dirname($candidate)); + } + + if ($realPath !== false && str_starts_with($realPath, $tempDir)) { + $paths[] = $realPath; + } + } + + return array_values(array_unique($paths)); + } +} diff --git a/src/Online/Sandbox/SandboxedCgiRunner.php b/src/Online/Sandbox/SandboxedCgiRunner.php new file mode 100644 index 00000000..c627512f --- /dev/null +++ b/src/Online/Sandbox/SandboxedCgiRunner.php @@ -0,0 +1,236 @@ +find('php-cgi'); + + if (null === $php) { + throw new RuntimeException('Could not load php-cgi binary. Please install php using your package manager.'); + } + + $this->phpLocation = $php; + } + + public function getName(): string + { + return 'CGI Program Runner'; + } + + /** + * @return array + */ + public function getRequiredChecks(): array + { + return [ + FileExistsCheck::class, + CodeExistsCheck::class, + PhpLintCheck::class, + CodeParseCheck::class, + ]; + } + + public function verify(Input $input): ResultInterface + { + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cgi.verify.start', $this->exercise, $input)); + $result = new CgiResult( + array_map( + fn(RequestInterface $request) => $this->checkRequest($request, $input), + $this->exercise->getRequests() + ) + ); + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cgi.verify.finish', $this->exercise, $input)); + return $result; + } + + public function run(Input $input, OutputInterface $output): bool + { + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cgi.run.start', $this->exercise, $input)); + $success = true; + foreach ($this->exercise->getRequests() as $request) { + /** @var CgiExecuteEvent $event */ + $event = $this->eventDispatcher->dispatch( + new CgiExecuteEvent('cgi.run.student-execute.pre', $request) + ); + $process = $this->getStudentProcess($input, $event->getRequest()); + + $process->start(); + $this->eventDispatcher->dispatch( + new CgiExecuteEvent('cgi.run.student.executing', $request, ['output' => $output]) + ); + $process->wait(function ($outputType, $outputBuffer) use ($output) { + $output->write($outputBuffer); + }); + $output->emptyLine(); + + if (!$process->isSuccessful()) { + $success = false; + } + + $output->lineBreak(); + + $this->eventDispatcher->dispatch( + new CgiExecuteEvent('cgi.run.student-execute.post', $request) + ); + } + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cgi.run.finish', $this->exercise, $input)); + return $success; + } + + private function checkRequest(RequestInterface $request, Input $input): CgiResultInterface + { + try { + /** @var CgiExecuteEvent $event */ + $event = $this->eventDispatcher->dispatch( + new CgiExecuteEvent('cgi.verify.reference-execute.pre', $request) + ); + $solutionResponse = $this->execute( + $this->getProcess( + $this->exercise->getSolution()->getEntryPoint()->getAbsolutePath(), + $event->getRequest(), + [] + ), + $event->getRequest(), + 'reference' + ); + } catch (CodeExecutionException $e) { + $this->eventDispatcher->dispatch(new Event('cgi.verify.reference-execute.fail', ['exception' => $e])); + throw new SolutionExecutionException($e->getMessage()); + } + + try { + /** @var CgiExecuteEvent $event */ + $event = $this->eventDispatcher->dispatch(new CgiExecuteEvent('cgi.verify.student-execute.pre', $request)); + $userResponse = $this->execute( + $this->getStudentProcess($input, $event->getRequest()), + $event->getRequest(), + 'student' + ); + } catch (CodeExecutionException $e) { + $this->eventDispatcher->dispatch(new Event('cgi.verify.student-execute.fail', ['exception' => $e])); + return GenericFailure::fromRequestAndCodeExecutionFailure($request, $e); + } + + $solutionBody = (string) $solutionResponse->getBody(); + $userBody = (string) $userResponse->getBody(); + $solutionHeaders = $this->getHeaders($solutionResponse); + $userHeaders = $this->getHeaders($userResponse); + + if ($solutionBody !== $userBody || $solutionHeaders !== $userHeaders) { + return new RequestFailure($request, $solutionBody, $userBody, $solutionHeaders, $userHeaders); + } + + return new Success($request); + } + + /** + * @return array + */ + private function getHeaders(ResponseInterface $response): array + { + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + return $headers; + } + + private function execute(Process $process, RequestInterface $request, string $type): ResponseInterface + { + $process->start(); + $this->eventDispatcher->dispatch(new CgiExecuteEvent(sprintf('cgi.verify.%s.executing', $type), $request)); + $process->wait(); + + if (!$process->isSuccessful()) { + throw CodeExecutionException::fromProcess($process); + } + + $output = $process->getOutput(); + if (!preg_match('/^HTTP\/([1-9]\d*\.\d) ([1-5]\d{2})(\s+(.+))?\\r\\n/', $output)) { + $output = "HTTP/1.0 200 OK\r\n" . $output; + } + + return Message::parseResponse($output); + } + + public function getStudentProcess(Input $input, RequestInterface $request): Process + { + $fileName = $input->getRequiredArgument('program'); + $root = $input->getArgument(SandboxedCliRunner::ROOT_ARGUMENT) ?? dirname($fileName); + + return $this->getProcess($fileName, $request, $this->sandbox->iniArguments($root)); + } + + /** + * @param list $iniArguments + */ + private function getProcess(string $fileName, RequestInterface $request, array $iniArguments): Process + { + $env = [ + 'REQUEST_METHOD' => $request->getMethod(), + 'SCRIPT_FILENAME' => $fileName, + 'REDIRECT_STATUS' => 302, + 'QUERY_STRING' => $request->getUri()->getQuery(), + 'REQUEST_URI' => $request->getUri()->getPath(), + 'XDEBUG_MODE' => 'off', + 'CONTENT_LENGTH' => (string) $request->getBody()->getSize(), + 'CONTENT_TYPE' => $request->getHeaderLine('Content-Type'), + ]; + + foreach ($request->getHeaders() as $name => $values) { + $env[sprintf('HTTP_%s', strtoupper((string) $name))] = implode(', ', $values); + } + + return new Process( + [ + $this->phpLocation, + '-dalways_populate_raw_post_data=-1', + '-dhtml_errors=0', + '-dexpose_php=0', + ...$iniArguments, + ], + dirname($fileName), + $this->sandbox->environment($env), + $request->getBody()->__toString(), + 10 + ); + } +} diff --git a/src/Online/Sandbox/SandboxedCliRunner.php b/src/Online/Sandbox/SandboxedCliRunner.php new file mode 100644 index 00000000..654c0025 --- /dev/null +++ b/src/Online/Sandbox/SandboxedCliRunner.php @@ -0,0 +1,232 @@ +find('php'); + + if (null === $php) { + throw new RuntimeException('Could not load php binary. Please install php using your package manager.'); + } + + $this->phpLocation = $php; + } + + public function getName(): string + { + return 'CLI Program Runner'; + } + + /** + * @return array + */ + public function getRequiredChecks(): array + { + return [ + FileExistsCheck::class, + CodeExistsCheck::class, + PhpLintCheck::class, + CodeParseCheck::class, + ]; + } + + public function verify(Input $input): ResultInterface + { + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cli.verify.start', $this->exercise, $input)); + $result = new CliResult( + array_map( + fn(array $args) => $this->doVerify($args, $input), + $this->preserveOldArgFormat($this->exercise->getArgs()) + ) + ); + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cli.verify.finish', $this->exercise, $input)); + return $result; + } + + public function run(Input $input, OutputInterface $output): bool + { + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cli.run.start', $this->exercise, $input)); + $success = true; + foreach ($this->preserveOldArgFormat($this->exercise->getArgs()) as $args) { + /** @var CliExecuteEvent $event */ + $event = $this->eventDispatcher->dispatch( + new CliExecuteEvent('cli.run.student-execute.pre', new ArrayObject($args)) + ); + + $args = $event->getArgs(); + + $process = $this->getStudentProcess($input, $args); + $process->start(); + $this->eventDispatcher->dispatch( + new CliExecuteEvent('cli.run.student.executing', $args, ['output' => $output]) + ); + $process->wait(function ($outputType, $outputBuffer) use ($output) { + $output->write($outputBuffer); + }); + $output->emptyLine(); + + if (!$process->isSuccessful()) { + $success = false; + } + + $output->lineBreak(); + + $this->eventDispatcher->dispatch( + new CliExecuteEvent('cli.run.student-execute.post', $args) + ); + } + + $this->eventDispatcher->dispatch(new ExerciseRunnerEvent('cli.run.finish', $this->exercise, $input)); + return $success; + } + + /** + * @param array $args + */ + private function doVerify(array $args, Input $input): CliResultInterface + { + $args = new ArrayObject($args); + + try { + /** @var CliExecuteEvent $event */ + $event = $this->eventDispatcher->dispatch(new CliExecuteEvent('cli.verify.reference-execute.pre', $args)); + $solutionOutput = $this->execute( + $this->getReferenceProcess($event->getArgs()), + $event->getArgs(), + 'reference' + ); + } catch (CodeExecutionException $e) { + $this->eventDispatcher->dispatch(new Event('cli.verify.reference-execute.fail', ['exception' => $e])); + throw new SolutionExecutionException($e->getMessage()); + } + + try { + /** @var CliExecuteEvent $event */ + $event = $this->eventDispatcher->dispatch(new CliExecuteEvent('cli.verify.student-execute.pre', $args)); + $userOutput = $this->execute( + $this->getStudentProcess($input, $event->getArgs()), + $event->getArgs(), + 'student' + ); + } catch (CodeExecutionException $e) { + $this->eventDispatcher->dispatch(new Event('cli.verify.student-execute.fail', ['exception' => $e])); + return GenericFailure::fromArgsAndCodeExecutionFailure($args, $e); + } + if ($solutionOutput === $userOutput) { + return new Success($event->getArgs()); + } + + return RequestFailure::fromArgsAndOutput($event->getArgs(), $solutionOutput, $userOutput); + } + + /** + * @param ArrayObject $args + */ + private function execute(Process $process, ArrayObject $args, string $type): string + { + $process->start(); + $this->eventDispatcher->dispatch(new CliExecuteEvent(sprintf('cli.verify.%s.executing', $type), $args)); + $process->wait(); + + if (!$process->isSuccessful()) { + throw CodeExecutionException::fromProcess($process); + } + + return $process->getOutput(); + } + + /** + * @param ArrayObject $args + */ + private function getReferenceProcess(ArrayObject $args): Process + { + $fileName = $this->exercise->getSolution()->getEntryPoint()->getAbsolutePath(); + + return new Process( + [$this->phpLocation, $fileName, ...$args->getArrayCopy()], + dirname($fileName), + $this->sandbox->environment(['XDEBUG_MODE' => 'off']), + null, + 10 + ); + } + + /** + * @param ArrayObject $args + */ + public function getStudentProcess(Input $input, ArrayObject $args): Process + { + $fileName = $input->getRequiredArgument('program'); + $root = $input->getArgument(self::ROOT_ARGUMENT) ?? dirname($fileName); + + return new Process( + [ + $this->phpLocation, + ...$this->sandbox->iniArguments( + $root, + $this->sandbox->readablePathsFromArguments($args->getArrayCopy()) + ), + $fileName, + ...$args->getArrayCopy(), + ], + dirname($fileName), + $this->sandbox->environment(['XDEBUG_MODE' => 'off']), + null, + 10 + ); + } + + /** + * @param array $args + * @return array> + */ + private function preserveOldArgFormat(array $args): array + { + if (isset($args[0]) && !is_array($args[0])) { + $args = [$args]; + } elseif (count($args) === 0) { + $args = [[]]; + } + + /** @var array> $args */ + return $args; + } +} diff --git a/src/Online/Sandbox/SandboxedRunnerFactory.php b/src/Online/Sandbox/SandboxedRunnerFactory.php new file mode 100644 index 00000000..ed0f2d16 --- /dev/null +++ b/src/Online/Sandbox/SandboxedRunnerFactory.php @@ -0,0 +1,47 @@ +getType()->getValue(), [ExerciseType::CLI, ExerciseType::CGI], true); + } + + public function configureInput(CommandDefinition $commandDefinition): void + { + $commandDefinition->addArgument(CommandArgument::required('program')); + } + + public function create(ExerciseInterface $exercise): ExerciseRunnerInterface + { + if ($exercise instanceof CliExercise && $exercise->getType()->getValue() === ExerciseType::CLI) { + return new SandboxedCliRunner($exercise, $this->eventDispatcher, $this->sandbox); + } + + if ($exercise instanceof CgiExercise && $exercise->getType()->getValue() === ExerciseType::CGI) { + return new SandboxedCgiRunner($exercise, $this->eventDispatcher, $this->sandbox); + } + + throw new RuntimeException(sprintf('Exercise "%s" cannot be run in the sandbox', $exercise->getName())); + } +} diff --git a/test/Cloud/Action/RunExerciseTest.php b/test/Cloud/Action/RunExerciseTest.php index 5bf995cb..d6f1eb9b 100644 --- a/test/Cloud/Action/RunExerciseTest.php +++ b/test/Cloud/Action/RunExerciseTest.php @@ -6,7 +6,9 @@ use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; +use PhpSchool\PhpWorkshop\Exception\CouldNotRunException; use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface; +use PhpSchool\PhpWorkshop\Result\Failure; use PhpSchool\PhpWorkshop\ExerciseDispatcher; use PhpSchool\PhpWorkshop\Input\Input; use PhpSchool\PhpWorkshop\Solution\DirectorySolution; @@ -15,6 +17,7 @@ use PhpSchool\Website\Online\CloudInstalledWorkshop; use PhpSchool\Website\Online\CloudWorkshopRepository; use PhpSchool\Website\Online\ProjectUploader; +use PhpSchool\Website\Online\Sandbox\SandboxedCliRunner; use PhpSchool\Website\Online\StudentCloudState; use PhpSchool\Website\Online\StudentWorkshopState; use PhpSchool\Website\User\SessionStorageInterface; @@ -195,6 +198,82 @@ public function testRunFail(): void $this->assertFileDoesNotExist($solution->getBaseDirectory()); } + public function testProjectIsRemovedWhenSolutionCouldNotRun(): void + { + $solution = $this->createProjectSolution(); + + [$installedWorkshopRepo, , $exerciseDispatcher, $session, $student] = $this->getDependencies(); + + $exerciseDispatcher->expects($this->once()) + ->method('run') + ->willThrowException(new CouldNotRunException(new Failure('Failure', 'Syntax error'))); + + $request = new ServerRequest('POST', '/run', [], json_encode([])); + + $uploader = $this->createMock(ProjectUploader::class); + $uploader->expects($this->once())->method('upload')->with($request, $student)->willReturn($solution); + $state = $this->createMock(StudentWorkshopState::class); + + $controller = new RunExercise($installedWorkshopRepo, $uploader, $state, $session); + + $actualResponse = $controller->__invoke($request, new Response(), 'workshop', 'exercise'); + + $this->assertFalse(json_decode($actualResponse->getBody()->__toString(), true)['success']); + $this->assertFileDoesNotExist($solution->getBaseDirectory()); + } + + public function testProjectIsRemovedWhenRunnerThrows(): void + { + $solution = $this->createProjectSolution(); + + [$installedWorkshopRepo, , $exerciseDispatcher, $session, $student] = $this->getDependencies(); + + $exerciseDispatcher->expects($this->once()) + ->method('run') + ->willThrowException(new \LogicException('Runner exploded')); + + $request = new ServerRequest('POST', '/run', [], json_encode([])); + + $uploader = $this->createMock(ProjectUploader::class); + $uploader->expects($this->once())->method('upload')->with($request, $student)->willReturn($solution); + $state = $this->createMock(StudentWorkshopState::class); + + $controller = new RunExercise($installedWorkshopRepo, $uploader, $state, $session); + + try { + $controller->__invoke($request, new Response(), 'workshop', 'exercise'); + $this->fail('Expected exception'); + } catch (\LogicException $e) { + } + + $this->assertFileDoesNotExist($solution->getBaseDirectory()); + } + + public function testProjectRootIsPassedToTheRunnerSoItCanBeSandboxed(): void + { + $solution = $this->createProjectSolution(); + + [$installedWorkshopRepo, $exercise, $exerciseDispatcher, $session, $student] = $this->getDependencies(); + + $exerciseDispatcher->expects($this->once()) + ->method('run') + ->with($exercise, $this->callback(function (Input $input) use ($solution) { + $this->assertEquals($solution->getBaseDirectory(), $input->getArgument(SandboxedCliRunner::ROOT_ARGUMENT)); + + return true; + })) + ->willReturn(true); + + $request = new ServerRequest('POST', '/run', [], json_encode([])); + + $uploader = $this->createMock(ProjectUploader::class); + $uploader->expects($this->once())->method('upload')->with($request, $student)->willReturn($solution); + $state = $this->createMock(StudentWorkshopState::class); + + $controller = new RunExercise($installedWorkshopRepo, $uploader, $state, $session); + $controller->__invoke($request, new Response(), 'workshop', 'exercise'); + } + private function createProjectSolution(string $entryPoint = 'solution.php'): DirectorySolution { $base = System::tempDir($this->getName()); diff --git a/test/Cloud/Action/VerifyExerciseTest.php b/test/Cloud/Action/VerifyExerciseTest.php index 0a956770..06e0d11e 100644 --- a/test/Cloud/Action/VerifyExerciseTest.php +++ b/test/Cloud/Action/VerifyExerciseTest.php @@ -246,6 +246,34 @@ public function testVerifyFail(): void $this->assertFileDoesNotExist($solution->getBaseDirectory()); } + public function testProjectIsRemovedWhenVerifyThrows(): void + { + $solution = $this->createProjectSolution(); + + [$installedWorkshopRepo, , $exerciseDispatcher, $session, $student] = $this->getDependencies(); + + $exerciseDispatcher->expects($this->once()) + ->method('verify') + ->willThrowException(new \LogicException('Runner exploded')); + + $request = new ServerRequest('POST', '/verify', [], json_encode([])); + + $renderer = $this->createMock(VueResultsRenderer::class); + $uploader = $this->createMock(ProjectUploader::class); + $uploader->expects($this->once())->method('upload')->with($request, $student)->willReturn($solution); + $state = $this->createMock(StudentWorkshopState::class); + + $controller = new VerifyExercise($installedWorkshopRepo, $uploader, $session, $state, $renderer); + + try { + $controller->__invoke($request, new Response(), 'workshop', 'exercise'); + $this->fail('Expected exception'); + } catch (\LogicException $e) { + } + + $this->assertFileDoesNotExist($solution->getBaseDirectory()); + } + private function createProjectSolution(string $entryPoint = 'solution.php'): DirectorySolution { $base = System::tempDir($this->getName()); diff --git a/test/Cloud/CloudInstalledWorkshopTest.php b/test/Cloud/CloudInstalledWorkshopTest.php index 3273d991..9806e99d 100644 --- a/test/Cloud/CloudInstalledWorkshopTest.php +++ b/test/Cloud/CloudInstalledWorkshopTest.php @@ -8,7 +8,14 @@ use PhpSchool\PhpWorkshop\ExerciseDispatcher; use PhpSchool\Website\Online\CloudInstalledWorkshop; use PhpSchool\Website\Entity\Workshop; +use PhpSchool\PhpWorkshop\Input\Input; +use PhpSchool\PhpWorkshop\Output\BufferedOutput; +use PhpSchool\PhpWorkshop\Solution\SingleFileSolution; +use PhpSchool\PhpWorkshop\Utils\System; +use PhpSchool\WebsiteTest\Asset\CliExerciseImpl; +use PhpSchool\Website\Online\Sandbox\SandboxedCliRunner; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; class CloudInstalledWorkshopTest extends TestCase { @@ -288,4 +295,48 @@ public function testJsonSerialise(): void (new CloudInstalledWorkshop($app, $workshop))->jsonSerialize() ); } + + public function testStudentCodeIsRunInTheSandbox(): void + { + $workshop = new Workshop( + 'php-school', + 'my-workshop', + 'myworkshop', + 'My Workshop', + 'My Workshop', + 'aydin@hotmail.co.uk', + 'Aydin Hassan' + ); + + $app = new Application('My Workshop', __DIR__ . '/_files/sandbox_di_config.php'); + $app->addExercise('Exercise1'); + + $cloudWorkshop = new CloudInstalledWorkshop($app, $workshop); + + $root = System::tempDir('cloud-sandbox-' . bin2hex(random_bytes(4))); + mkdir($root, 0777, true); + file_put_contents($root . '/solution.php', 'findExerciseByName('Exercise 1'); + $exercise->setSolution(SingleFileSolution::fromFile($root . '/reference.php')); + + $output = new BufferedOutput(); + + try { + $cloudWorkshop->getExerciseDispatcher()->run( + $exercise, + new Input('myworkshop', ['program' => $root . '/solution.php', SandboxedCliRunner::ROOT_ARGUMENT => $root]), + $output + ); + } finally { + (new Filesystem())->remove($root); + } + + $result = $output->fetch(); + $this->assertStringContainsString('Call to undefined function shell_exec()', $result); + $this->assertStringNotContainsString('SANDBOX_ESCAPED', $result); + } } diff --git a/test/Cloud/ProjectUploaderTest.php b/test/Cloud/ProjectUploaderTest.php index f4228194..05ef32ae 100644 --- a/test/Cloud/ProjectUploaderTest.php +++ b/test/Cloud/ProjectUploaderTest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\ServerRequest; use PhpSchool\PhpWorkshop\Solution\SolutionFile; +use PhpSchool\Website\Online\ComposerPackageAllowlist; use PhpSchool\Website\Online\PathGenerator; use PhpSchool\Website\Online\ProjectUploader; use PhpSchool\Website\Online\StudentCloudState; @@ -207,6 +208,7 @@ public function testComposerDependenciesAreInstalledIfDependenciesAreSpecified() ]) ); + $uploader = new ProjectUploader($generator, $this->getAllowlist()); $result = $uploader->upload($request, $student); $this->assertEquals($path, $result->getBaseDirectory()); @@ -225,7 +227,10 @@ public function testComposerDependenciesAreInstalledIfDependenciesAreSpecified() $this->assertFileExists($path . '/composer.lock'); $this->assertFileExists($path . '/composer.json'); - $this->assertEquals('{"require":{"symfony\/console":"6.2.0"}}', file_get_contents($path . '/composer.json')); + $this->assertEquals( + '{"require":{"symfony\/console":"6.2.0"},"config":{"allow-plugins":false}}', + file_get_contents($path . '/composer.json') + ); } public function testWithAlternateEntryPoint(): void @@ -277,6 +282,151 @@ public function testWithAlternateEntryPoint(): void $this->assertEquals('getStudent(); + $path = $this->getTemporaryDirectoryWithoutCreating(); + $generator = $this->createMock(PathGenerator::class); + $generator->expects($this->never())->method('random'); + + $uploader = new ProjectUploader($generator, $this->getAllowlist()); + + $request = new ServerRequest( + 'POST', + '/verify', + [], + json_encode([ + 'scripts' => ['solution.php' => ' $dependencies, + ]) + ); + + try { + $uploader->upload($request, $student); + $this->fail('Expected exception'); + } catch (\RuntimeException $e) { + $this->assertEquals($message, $e->getMessage()); + } + + $this->assertFileDoesNotExist($path); + } + + /** + * @return array + */ + public function rejectedComposerDependenciesProvider(): array + { + return [ + 'package not in the allowlist' => [ + [['name' => 'evil/composer-plugin', 'version' => '1.0.0']], + 'Composer package "evil/composer-plugin" is not allowed', + ], + 'invalid package name' => [ + [['name' => '../../etc/passwd', 'version' => '1.0.0']], + 'Composer package "../../etc/passwd" is not allowed', + ], + 'vcs style version' => [ + [['name' => 'symfony/console', 'version' => 'dev-main#abc"; rm -rf /']], + 'Invalid version constraint for composer package "symfony/console"', + ], + 'not a list' => ['symfony/console', 'Invalid composer dependencies'], + 'missing version' => [[['name' => 'symfony/console']], 'Invalid composer dependencies'], + 'too many packages' => [ + array_fill(0, 11, ['name' => 'psr/log', 'version' => '^3.0']), + 'A maximum of 10 composer dependencies are allowed', + ], + ]; + } + + public function testComposerDependenciesAreRejectedWhenThePackageListIsMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Composer package "symfony/console" is not allowed'); + + $uploader = new ProjectUploader( + new PathGenerator(), + new ComposerPackageAllowlist(__DIR__ . '/_files/does-not-exist.json') + ); + + $request = new ServerRequest( + 'POST', + '/verify', + [], + json_encode([ + 'scripts' => ['solution.php' => ' [['name' => 'symfony/console', 'version' => '6.2.0']], + ]) + ); + + $uploader->upload($request, $this->getStudent()); + } + + public function testProjectIsRemovedWhenComposerInstallFails(): void + { + $student = $this->getStudent(); + $path = $this->getTemporaryDirectoryWithoutCreating(); + $generator = $this->createMock(PathGenerator::class); + $generator->expects($this->once()) + ->method('random') + ->with($student) + ->willReturn($path); + + $uploader = new ProjectUploader($generator, $this->getAllowlist()); + + $request = new ServerRequest( + 'POST', + '/verify', + [], + json_encode([ + 'scripts' => ['solution.php' => ' [['name' => 'psr/log', 'version' => '999.0.0']], + ]) + ); + + try { + $uploader->upload($request, $student); + $this->fail('Expected exception'); + } catch (\RuntimeException $e) { + $this->assertEquals('Composer dependencies could not be installed', $e->getMessage()); + } + + $this->assertFileDoesNotExist($path); + } + + public function testNonStringScriptContentsAreRejected(): void + { + $student = $this->getStudent(); + $path = $this->getTemporaryDirectoryWithoutCreating(); + $generator = $this->createMock(PathGenerator::class); + $generator->method('random')->willReturn($path); + + $uploader = new ProjectUploader($generator, $this->getAllowlist()); + + $request = new ServerRequest( + 'POST', + '/verify', + [], + json_encode(['scripts' => ['solution.php' => ['not', 'a', 'string']]]) + ); + + try { + $uploader->upload($request, $student); + $this->fail('Expected exception'); + } catch (\RuntimeException $e) { + $this->assertEquals('Invalid file contents', $e->getMessage()); + } + + $this->assertFileDoesNotExist($path); + } + + private function getAllowlist(): ComposerPackageAllowlist + { + return new ComposerPackageAllowlist(__DIR__ . '/_files/packages.json'); + } + private function getStudent(): StudentDTO { return new StudentDTO( diff --git a/test/Cloud/Sandbox/SandboxedCgiRunnerTest.php b/test/Cloud/Sandbox/SandboxedCgiRunnerTest.php new file mode 100644 index 00000000..58cef0b3 --- /dev/null +++ b/test/Cloud/Sandbox/SandboxedCgiRunnerTest.php @@ -0,0 +1,94 @@ +remove($this->getTemporaryDirectoryWithoutCreating()); + unset($_ENV['SANDBOX_TEST_SECRET']); + } + + public function testStudentRequestIsHandled(): void + { + $output = $this->runStudentCode('assertStringContainsString('method:POST body:name=aydin', $output); + } + + public function testStudentCannotReadFilesOutsideItsDirectory(): void + { + $output = $this->runStudentCode( + sprintf('assertStringNotContainsString('class SandboxedCgiRunnerTest', $output); + } + + public function testShellFunctionsAreDisabled(): void + { + $output = $this->runStudentCode('assertStringContainsString('Call to undefined function', $output); + $this->assertStringNotContainsString('SANDBOX_ESCAPED', $output); + } + + public function testApplicationEnvironmentIsNotInherited(): void + { + $_ENV['SANDBOX_TEST_SECRET'] = 'from-dotenv'; + + $output = $this->runStudentCode('assertStringNotContainsString('from-dotenv', $output); + } + + private function runStudentCode(string $code): string + { + $program = $this->getTemporaryFile('solution.php', $code); + + $exercise = new class () extends CliExerciseImpl implements CgiExercise { + public function getRequests(): array + { + return [ + (new Request('POST', 'http://www.time.com/api', ['Content-Type' => 'application/x-www-form-urlencoded'], 'name=aydin')), + ]; + } + + public function getType(): ExerciseType + { + return ExerciseType::CGI(); + } + }; + + $runner = new SandboxedCgiRunner( + $exercise, + new EventDispatcher(new ResultAggregator()), + new PhpSandbox() + ); + + $output = new BufferedOutput(); + $runner->run( + new Input('app', ['program' => $program, SandboxedCliRunner::ROOT_ARGUMENT => $this->getTemporaryDirectory()]), + $output + ); + + return $output->fetch(); + } +} diff --git a/test/Cloud/Sandbox/SandboxedCliRunnerTest.php b/test/Cloud/Sandbox/SandboxedCliRunnerTest.php new file mode 100644 index 00000000..2ae8993c --- /dev/null +++ b/test/Cloud/Sandbox/SandboxedCliRunnerTest.php @@ -0,0 +1,181 @@ +outsideDirectory = System::tempDir('sandbox-outside-' . bin2hex(random_bytes(4))); + mkdir($this->outsideDirectory, 0777, true); + file_put_contents($this->outsideDirectory . '/.env', 'JWT_SECRET=top-secret'); + } + + public function tearDown(): void + { + (new Filesystem())->remove([$this->outsideDirectory, $this->getTemporaryDirectoryWithoutCreating()]); + unset($_ENV['SANDBOX_TEST_SECRET'], $_SERVER['SANDBOX_TEST_SECRET']); + putenv('SANDBOX_TEST_SECRET'); + } + + public function testStudentCanReadFilesInsideItsOwnDirectory(): void + { + $this->getTemporaryFile('data.txt', 'hello from inside'); + + $output = $this->runStudentCode('assertStringContainsString('hello from inside', $output); + } + + public function testStudentCannotReadFilesOutsideItsDirectory(): void + { + $output = $this->runStudentCode( + sprintf('outsideDirectory . '/.env', true)) + ); + + $this->assertStringContainsString('bool(false)', $output); + $this->assertStringNotContainsString('top-secret', $output); + } + + public function testStudentCannotReadApplicationFiles(): void + { + $output = $this->runStudentCode( + sprintf('assertStringNotContainsString('class SandboxedCliRunnerTest', $output); + } + + public function testStudentCanReadPathsPassedAsArguments(): void + { + $file = $this->outsideDirectory . '/input.txt'; + file_put_contents($file, 'exercise input'); + + $output = $this->runStudentCode('assertStringContainsString('exercise input', $output); + } + + public function testMissingPathsPassedAsArgumentsCanBeCheckedWithoutWarnings(): void + { + $file = $this->outsideDirectory . '/missing.txt'; + + $output = $this->runStudentCode('assertStringNotContainsString('open_basedir', $output); + $this->assertStringContainsString('bool(false)', $output); + } + + /** + * @dataProvider disabledFunctionProvider + */ + public function testDangerousFunctionsAreDisabled(string $code): void + { + $output = $this->runStudentCode('assertStringContainsString('Call to undefined function', $output); + $this->assertStringNotContainsString('SANDBOX_ESCAPED', $output); + } + + /** + * @return array> + */ + public function disabledFunctionProvider(): array + { + return [ + ['echo shell_exec("echo SANDBOX_ESCAPED");'], + ['system("echo SANDBOX_ESCAPED");'], + ['exec("echo SANDBOX_ESCAPED", $o); echo implode($o);'], + ['passthru("echo SANDBOX_ESCAPED");'], + ['proc_open("echo SANDBOX_ESCAPED", [], $pipes);'], + ['popen("echo SANDBOX_ESCAPED", "r");'], + ['putenv("LD_PRELOAD=/tmp/evil.so");'], + ['mail("a@b.c", "s", "m");'], + ['stream_socket_client("tcp://127.0.0.1:6379");'], + ['fsockopen("127.0.0.1", 6379);'], + ]; + } + + public function testRuntimeLimitsAreApplied(): void + { + $output = $this->runStudentCode( + 'assertStringContainsString('128M|0|0', $output); + } + + public function testApplicationEnvironmentIsNotInherited(): void + { + $_ENV['SANDBOX_TEST_SECRET'] = 'from-dotenv'; + $_SERVER['SANDBOX_TEST_SECRET'] = 'from-dotenv'; + putenv('SANDBOX_TEST_SECRET=from-dotenv'); + + $output = $this->runStudentCode('assertStringContainsString('bool(false)', $output); + $this->assertStringNotContainsString('from-dotenv', $output); + } + + public function testSandboxCannotBeLoosenedAtRuntime(): void + { + $output = $this->runStudentCode( + sprintf( + 'outsideDirectory . '/.env', true) + ) + ); + + $this->assertStringContainsString('bool(false)', $output); + } + + /** + * @param array $args + */ + private function runStudentCode(string $code, array $args = []): string + { + $program = $this->getTemporaryFile('solution.php', $code); + + $exercise = new class ($args) extends CliExerciseImpl { + /** @param array $args */ + public function __construct(private array $args) + { + parent::__construct(); + } + + public function getArgs(): array + { + return [$this->args]; + } + }; + + $runner = new SandboxedCliRunner( + $exercise, + new EventDispatcher(new ResultAggregator()), + new PhpSandbox() + ); + + $output = new BufferedOutput(); + $runner->run( + new Input('app', ['program' => $program, SandboxedCliRunner::ROOT_ARGUMENT => $this->getTemporaryDirectory()]), + $output + ); + + return $output->fetch(); + } +} diff --git a/test/Cloud/_files/packages.json b/test/Cloud/_files/packages.json new file mode 100644 index 00000000..7e836573 --- /dev/null +++ b/test/Cloud/_files/packages.json @@ -0,0 +1 @@ +{"packageNames":["symfony/console","psr/log"]} diff --git a/test/Cloud/_files/sandbox_di_config.php b/test/Cloud/_files/sandbox_di_config.php new file mode 100644 index 00000000..d1217d1b --- /dev/null +++ b/test/Cloud/_files/sandbox_di_config.php @@ -0,0 +1,11 @@ + function () { + return new CliExerciseImpl('Exercise 1'); + }, +];