diff --git a/lib/Web/DevServer.php b/lib/Web/DevServer.php index 948c340e..ac2a13a8 100644 --- a/lib/Web/DevServer.php +++ b/lib/Web/DevServer.php @@ -130,6 +130,12 @@ public static function handleConnection($conn, string $docroot, callable $handle self::clearHttpServerKeys(); $cgiEnv = array_merge($cgiEnv, Superglobals::applyHttpHeaders($headers)); + $contentLength = self::contentLengthForRequest($headers, $body); + if (null !== $contentLength) { + $cgiEnv['CONTENT_LENGTH'] = $contentLength; + $_SERVER['CONTENT_LENGTH'] = $contentLength; + putenv('CONTENT_LENGTH='.$contentLength); + } putenv('REQUEST_METHOD='.$method); putenv('QUERY_STRING='.$query); @@ -205,6 +211,21 @@ public static function readRequest($conn): ?array return [$method, $path, $query, $headers, $body]; } + /** + * CGI CONTENT_LENGTH for incoming requests when Content-Length was sent. + * + * Uses the bytes actually read (not the header alone). Absent for chunked + * requests without Content-Length (issue #287). + */ + public static function contentLengthForRequest(array $headers, string $body): ?string + { + if (!isset($headers['content-length'])) { + return null; + } + + return (string) strlen($body); + } + /** * Map an HTTP header name to a CGI $_SERVER key (e.g. host → HTTP_HOST). */ diff --git a/test/fixtures/aot/cases/web_content_length.phpt b/test/fixtures/aot/cases/web_content_length.phpt new file mode 100644 index 00000000..8d8930ff --- /dev/null +++ b/test/fixtures/aot/cases/web_content_length.phpt @@ -0,0 +1,13 @@ +--TEST-- +AOT: CONTENT_LENGTH from CGI env (issue #314) +--ENV-- +REQUEST_METHOD=POST +CONTENT_LENGTH=12 +REQUEST_BODY=abcdefghijkl +--FILE-- +makeDocroot([ + 'length.php' => <<<'PHP' +assertTrue(mkdir($binaryDir)); + $binary = $binaryDir.'/app'; + $this->compileExample($docroot.'/length.php', $binary); + $response = $this->httpPostAot($docroot, $binary, '/length.php', $body); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString('12', $response); + @unlink($binary); + @rmdir($binaryDir); + } + public function testServeAot001SimpleWeb(): void { $docroot = $this->repoRoot.'/examples/001-SimpleWeb'; @@ -174,6 +196,60 @@ private function httpGetAot(string $docroot, string $binary, string $path): stri return $response !== false ? $response : ''; } + private function httpPostAot(string $docroot, string $binary, string $path, string $body): string + { + $port = $this->findFreePort(); + $addr = "127.0.0.1:{$port}"; + $env = array_merge($this->baseEnv(), $this->llvmEnv()); + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $cmd = array_merge( + $this->phpCmd, + [$this->repoRoot.'/bin/serve-aot.php', $addr, $docroot, '--binary', $binary] + ); + $proc = proc_open($cmd, $descriptorSpec, $pipes, $this->repoRoot, $env); + $this->assertIsResource($proc); + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $deadline = microtime(true) + 15.0; + $ready = false; + while (microtime(true) < $deadline) { + $conn = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.2); + if (false !== $conn) { + $ready = true; + fclose($conn); + break; + } + usleep(50_000); + } + $this->assertTrue($ready, 'serve-aot did not become ready'); + + $conn = fsockopen('127.0.0.1', $port); + $this->assertIsResource($conn); + $len = strlen($body); + fwrite( + $conn, + "POST {$path} HTTP/1.1\r\n" + ."Host: 127.0.0.1\r\n" + ."Content-Type: application/x-www-form-urlencoded\r\n" + ."Content-Length: {$len}\r\n" + ."Connection: close\r\n\r\n" + .$body + ); + $response = stream_get_contents($conn); + fclose($conn); + + proc_terminate($proc); + proc_close($proc); + + return $response !== false ? $response : ''; + } + /** * @return array */ diff --git a/test/real/ServeTest.php b/test/real/ServeTest.php index b04e29e3..4daf08b5 100644 --- a/test/real/ServeTest.php +++ b/test/real/ServeTest.php @@ -149,6 +149,21 @@ public function testPopulatesCookieFromRequestHeader(): void $this->assertStringContainsString('dark', $response); } + public function testPopulatesContentLengthOnPost(): void + { + $body = 'abcdefghijkl'; + $docroot = $this->makeDocroot([ + 'length.php' => <<<'PHP' +httpPost($docroot, '/length.php', $body); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString('12', $response); + } + /** * @param array $extraEnv * @param list $extraRequestHeaders @@ -197,6 +212,57 @@ private function httpGet(string $docroot, string $path, array $extraEnv = [], ar return $response !== false ? $response : ''; } + private function httpPost(string $docroot, string $path, string $body, array $extraEnv = []): string + { + $port = $this->findFreePort(); + $addr = "127.0.0.1:{$port}"; + $env = array_merge($this->baseEnv(), $extraEnv); + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $cmd = array_merge($this->phpCmd, [$this->repoRoot.'/bin/serve.php', $addr, $docroot]); + $proc = proc_open($cmd, $descriptorSpec, $pipes, $this->repoRoot, $env); + $this->assertIsResource($proc); + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $deadline = microtime(true) + 5.0; + $ready = false; + while (microtime(true) < $deadline) { + $conn = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.2); + if (false !== $conn) { + $ready = true; + fclose($conn); + break; + } + usleep(50_000); + } + $this->assertTrue($ready, 'serve did not become ready'); + + $conn = fsockopen('127.0.0.1', $port); + $this->assertIsResource($conn); + $len = strlen($body); + fwrite( + $conn, + "POST {$path} HTTP/1.1\r\n" + ."Host: 127.0.0.1\r\n" + ."Content-Type: application/x-www-form-urlencoded\r\n" + ."Content-Length: {$len}\r\n" + ."Connection: close\r\n\r\n" + .$body + ); + $response = stream_get_contents($conn); + fclose($conn); + + proc_terminate($proc); + proc_close($proc); + + return $response !== false ? $response : ''; + } + /** * @param array $files relative path => contents */ diff --git a/test/real/cases/web_content_length.phpt b/test/real/cases/web_content_length.phpt new file mode 100644 index 00000000..5d278f21 --- /dev/null +++ b/test/real/cases/web_content_length.phpt @@ -0,0 +1,13 @@ +--TEST-- +Web: CONTENT_LENGTH from CGI env (issue #314) +--ENV-- +REQUEST_METHOD=POST +CONTENT_LENGTH=12 +REQUEST_BODY=abcdefghijkl +--FILE-- +assertSame([], $vars); } + + public function testContentLengthForRequestUsesBodySize(): void + { + $body = 'abcdefghijkl'; + $this->assertSame('12', DevServer::contentLengthForRequest(['content-length' => '99'], $body)); + $this->assertNull(DevServer::contentLengthForRequest([], $body)); + } } diff --git a/test/unit/Web/SuperglobalsContentLengthTest.php b/test/unit/Web/SuperglobalsContentLengthTest.php new file mode 100644 index 00000000..b1976bb2 --- /dev/null +++ b/test/unit/Web/SuperglobalsContentLengthTest.php @@ -0,0 +1,56 @@ +assertSame('12', DevServer::contentLengthForRequest(['content-length' => '12'], $body)); + } + + public function testContentLengthForRequestAbsentWithoutHeader(): void + { + $this->assertNull(DevServer::contentLengthForRequest([], 'body')); + } + + public function testApplyHttpHeadersMapsContentLength(): void + { + $cgi = Superglobals::applyHttpHeaders(['content-length' => '12']); + $this->assertSame(['CONTENT_LENGTH' => '12'], $cgi); + $this->assertSame('12', $_SERVER['CONTENT_LENGTH'] ?? ''); + } + + public function testPopulateFromEnvironmentReadsContentLength(): void + { + putenv('CONTENT_LENGTH=12'); + $_SERVER['CONTENT_LENGTH'] = '12'; + $runtime = new Runtime(); + Superglobals::populateFromEnvironment($runtime->vmContext, '', 'abcdefghijkl'); + + $server = $runtime->vmContext->getSuperglobal('_SERVER')->toArray(); + $var = $server->find('CONTENT_LENGTH'); + $this->assertNotNull($var); + $this->assertSame('12', $var->resolveIndirect()->toString()); + } +}