From 996f7d1577d89fd7f250b65e8a8cc7e63ba76991 Mon Sep 17 00:00:00 2001 From: PurHur Date: Tue, 19 May 2026 16:09:43 +0000 Subject: [PATCH] Web: populate $_SERVER['DOCUMENT_ROOT'] from CGI env (fixes #296) Expose the serve docroot in VM and AOT superglobals so scripts can build docroot-relative paths. DevServer sets DOCUMENT_ROOT per request; AOT refresh copies it from the environment. Includes VM/AOT/serve regression tests and fixes ServeAotTest compile env PATH merging. Co-authored-by: Cursor --- docs/bootstrap-inventory.md | 8 +-- lib/AOT/runtime/superglobals_refresh.c | 8 +++ lib/Web/DevServer.php | 2 + lib/Web/Superglobals.php | 5 ++ test/aot/RuntimeSuperglobalRefreshTest.php | 52 +++++++++++++++ .../fixtures/aot/cases/web_document_root.phpt | 14 +++++ test/real/ServeAotTest.php | 55 +++++++++++++++- test/real/ServeTest.php | 15 +++++ test/real/cases/web_document_root.phpt | 12 ++++ .../unit/Web/SuperglobalsDocumentRootTest.php | 63 +++++++++++++++++++ 10 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/aot/cases/web_document_root.phpt create mode 100644 test/real/cases/web_document_root.phpt create mode 100644 test/unit/Web/SuperglobalsDocumentRootTest.php diff --git a/docs/bootstrap-inventory.md b/docs/bootstrap-inventory.md index 54af49eb..66ba038e 100644 --- a/docs/bootstrap-inventory.md +++ b/docs/bootstrap-inventory.md @@ -1501,10 +1501,10 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: ### `lib/Web/Superglobals.php` **Warnings** (review for bootstrap subset): -- new HashTable (line 406) -- new Variable (line 407) -- new Variable (line 426) -- new Variable (line 476) +- new HashTable (line 411) +- new Variable (line 412) +- new Variable (line 431) +- new Variable (line 481) - 22 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler ### `src/macro_functions.php` diff --git a/lib/AOT/runtime/superglobals_refresh.c b/lib/AOT/runtime/superglobals_refresh.c index 0e135160..e867d8e9 100644 --- a/lib/AOT/runtime/superglobals_refresh.c +++ b/lib/AOT/runtime/superglobals_refresh.c @@ -520,6 +520,14 @@ void __superglobals__refresh(void) set_string_key(sg_SERVER, "GATEWAY_INTERFACE", "CGI/1.1"); set_string_key(sg_SERVER, "SERVER_SOFTWARE", "PHP-Compiler-AOT"); + { + const char *document_root = getenv("DOCUMENT_ROOT"); + + if (NULL != document_root && '\0' != document_root[0]) { + set_string_key(sg_SERVER, "DOCUMENT_ROOT", document_root); + } + } + derive_path_info(script_name, request_uri, path_info, sizeof(path_info)); if ('\0' != path_info[0]) { set_string_key(sg_SERVER, "PATH_INFO", path_info); diff --git a/lib/Web/DevServer.php b/lib/Web/DevServer.php index 80ae3ba6..d24a8ca8 100644 --- a/lib/Web/DevServer.php +++ b/lib/Web/DevServer.php @@ -116,6 +116,7 @@ public static function handleConnection($conn, string $docroot, callable $handle 'REQUEST_BODY' => $body, 'SCRIPT_NAME' => $scriptName, 'REQUEST_URI' => $requestUri, + 'DOCUMENT_ROOT' => $docroot, ]; if ('' !== $pathInfo) { $cgiEnv['PATH_INFO'] = $pathInfo; @@ -129,6 +130,7 @@ public static function handleConnection($conn, string $docroot, callable $handle putenv('REQUEST_BODY='.$body); putenv('SCRIPT_NAME='.$scriptName); putenv('REQUEST_URI='.$requestUri); + putenv('DOCUMENT_ROOT='.$docroot); if ('' !== $pathInfo) { putenv('PATH_INFO='.$pathInfo); } else { diff --git a/lib/Web/Superglobals.php b/lib/Web/Superglobals.php index 56ee12e5..c3d91d62 100644 --- a/lib/Web/Superglobals.php +++ b/lib/Web/Superglobals.php @@ -181,6 +181,11 @@ private static function populateServer( self::setStringEntry($server, 'GATEWAY_INTERFACE', 'CGI/1.1'); self::setStringEntry($server, 'SERVER_SOFTWARE', 'PHP-Compiler-VM'); + $documentRoot = getenv('DOCUMENT_ROOT'); + if (false !== $documentRoot && '' !== $documentRoot) { + self::setStringEntry($server, 'DOCUMENT_ROOT', $documentRoot); + } + foreach (array_merge($_ENV, $_SERVER) as $key => $value) { if (!is_string($key) || !is_string($value)) { continue; diff --git a/test/aot/RuntimeSuperglobalRefreshTest.php b/test/aot/RuntimeSuperglobalRefreshTest.php index f0b44cdc..251b0cec 100644 --- a/test/aot/RuntimeSuperglobalRefreshTest.php +++ b/test/aot/RuntimeSuperglobalRefreshTest.php @@ -184,6 +184,58 @@ public function testCookieFromHttpCookieEnv(): void @unlink($outfile); } + public function testDocumentRootFromCgiEnvironment(): void + { + $source = <<<'PHP' +assertNotFalse($outfile); + unlink($outfile); + + $repoRoot = dirname(__DIR__, 2); + $env = $this->llvmProcessEnv($repoRoot); + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $compile = proc_open( + array_merge( + self::llvmEnvPrefix(), + self::phpCommand(), + [$this->compileBin, '-o', $outfile] + ), + $descriptorSpec, + $pipes, + $repoRoot, + $env + ); + fwrite($pipes[0], $source); + fclose($pipes[0]); + $compileErr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($compile); + $this->assertFileExists($outfile, trim($compileErr !== false ? $compileErr : '')); + + $root = realpath(sys_get_temp_dir()); + $this->assertNotFalse($root); + $runEnv = $env; + $runEnv['DOCUMENT_ROOT'] = $root; + $runEnv['SCRIPT_NAME'] = '/index.php'; + $runEnv['REQUEST_URI'] = '/index.php'; + $output = $this->runBinary($outfile, $runEnv); + $this->assertStringContainsString($root, $output); + + @unlink($outfile); + } + public function testHttpHostFromCgiEnvironment(): void { $source = <<<'PHP' diff --git a/test/fixtures/aot/cases/web_document_root.phpt b/test/fixtures/aot/cases/web_document_root.phpt new file mode 100644 index 00000000..8a8f95c7 --- /dev/null +++ b/test/fixtures/aot/cases/web_document_root.phpt @@ -0,0 +1,14 @@ +--TEST-- +AOT: DOCUMENT_ROOT from CGI env (issue #296) +--ENV-- +REQUEST_METHOD=GET +SCRIPT_NAME=/index.php +REQUEST_URI=/index.php +DOCUMENT_ROOT=/var/www/html +--FILE-- +phpCmd = self::phpCommand(); } + public function testServeAotPopulatesDocumentRoot(): void + { + $docroot = $this->makeDocroot([ + 'docroot.php' => <<<'PHP' +assertNotFalse($resolved); + $binaryDir = sys_get_temp_dir().'/phpc_serve_aot_dr_'.bin2hex(random_bytes(4)); + $this->assertTrue(mkdir($binaryDir)); + $binary = $binaryDir.'/app'; + $this->compileExample($docroot.'/docroot.php', $binary); + $response = $this->httpGetAot($docroot, $binary, '/docroot.php'); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString($resolved, $response); + @unlink($binary); + @rmdir($binaryDir); + } + public function testServeAot001SimpleWeb(): void { $docroot = $this->repoRoot.'/examples/001-SimpleWeb'; @@ -57,7 +80,8 @@ private function compileExample(string $source, string $outfile): void 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; - $env = $this->llvmEnv(); + $env = $this->baseEnv(); + LlvmToolchain::applyProcessEnv($env, $this->repoRoot); $compile = proc_open( array_merge( self::llvmEnvPrefix(), @@ -73,7 +97,12 @@ private function compileExample(string $source, string $outfile): void $err = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); - proc_close($compile); + $exitCode = proc_close($compile); + $this->assertSame( + 0, + $exitCode, + 'compile.php failed: '.trim($err !== false ? $err : '') + ); $this->assertFileExists($outfile, trim($err !== false ? $err : '')); } @@ -133,6 +162,25 @@ private function llvmEnv(): array return $env; } + /** + * @param array $files relative path => contents + */ + private function makeDocroot(array $files): string + { + $dir = sys_get_temp_dir().'/phpc_serve_aot_'.bin2hex(random_bytes(4)); + $this->assertTrue(mkdir($dir)); + foreach ($files as $name => $contents) { + $path = $dir.'/'.$name; + $parent = dirname($path); + if (!is_dir($parent)) { + mkdir($parent, 0777, true); + } + file_put_contents($path, $contents); + } + + return $dir; + } + private function findFreePort(): int { $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); @@ -167,6 +215,9 @@ private static function phpCommand(): array $phpEnv = getenv('PHP_COMPILER_PHP'); if (false !== $phpEnv && '' !== $phpEnv) { $cmd = preg_split('/\s+/', $phpEnv); + if (!str_contains($cmd[0], '/')) { + $cmd[0] = PHP_BINARY; + } } else { $cmd = [PHP_BINARY]; } diff --git a/test/real/ServeTest.php b/test/real/ServeTest.php index 7e9e4b18..55a9a338 100644 --- a/test/real/ServeTest.php +++ b/test/real/ServeTest.php @@ -104,6 +104,21 @@ public function testPopulatesHttpServerHeaders(): void $this->assertStringContainsString('example.test|1', $response); } + public function testPopulatesDocumentRoot(): void + { + $docroot = $this->makeDocroot([ + 'docroot.php' => <<<'PHP' +assertNotFalse($resolved); + $response = $this->httpGet($docroot, '/docroot.php'); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString($resolved, $response); + } + public function testPopulatesCookieFromRequestHeader(): void { $docroot = $this->makeDocroot([ diff --git a/test/real/cases/web_document_root.phpt b/test/real/cases/web_document_root.phpt new file mode 100644 index 00000000..fa36403a --- /dev/null +++ b/test/real/cases/web_document_root.phpt @@ -0,0 +1,12 @@ +--TEST-- +Web: DOCUMENT_ROOT from CGI env (issue #296) +--ENV-- +REQUEST_METHOD=GET +SCRIPT_NAME=/index.php +REQUEST_URI=/index.php +DOCUMENT_ROOT=/var/www/html +--FILE-- +runtime = new Runtime(); + } + + protected function tearDown(): void + { + putenv('DOCUMENT_ROOT'); + unset($_SERVER['DOCUMENT_ROOT']); + } + + public function testDocumentRootFromEnvironment(): void + { + $root = realpath(sys_get_temp_dir()); + $this->assertNotFalse($root); + putenv('DOCUMENT_ROOT='.$root); + + Superglobals::populateFromEnvironment($this->runtime->vmContext, '', ''); + + $server = $this->runtime->vmContext->getSuperglobal('_SERVER')->toArray(); + $this->assertSame($root, $this->readServer($server, 'DOCUMENT_ROOT')); + } + + public function testDocumentRootOmittedWhenUnset(): void + { + putenv('DOCUMENT_ROOT'); + + Superglobals::populateFromEnvironment($this->runtime->vmContext, '', ''); + + $server = $this->runtime->vmContext->getSuperglobal('_SERVER')->toArray(); + $this->assertSame('', $this->readServer($server, 'DOCUMENT_ROOT')); + } + + private function readServer(\PHPCompiler\VM\HashTable $server, string $key): string + { + $var = $server->find($key); + if (null === $var) { + return ''; + } + $resolved = $var->resolveIndirect(); + if (\PHPCompiler\VM\Variable::TYPE_STRING !== $resolved->type) { + return ''; + } + + return $resolved->toString(); + } +}