diff --git a/lib/VM/HashTable.php b/lib/VM/HashTable.php index 40dd2b889b5..0abb56f6e54 100755 --- a/lib/VM/HashTable.php +++ b/lib/VM/HashTable.php @@ -581,10 +581,11 @@ private function rehash(): void { if ($this->isWithoutHoles()) { do { $bucket = $this->buckets->read($bucketIndex); - $index = $bucket->hash; - $bucket->value->next = $index; - $this->indexes->write($index, $bucketIndex); + $hash = $bucket->hash; + $bucket->value->next = $this->indexes->read($hash); + $this->indexes->write($hash, $bucketIndex); } while (++$bucketIndex < $this->numUsed); + return; } //todo diff --git a/lib/Web/DevServer.php b/lib/Web/DevServer.php index 10f64acd554..1171cf7abea 100644 --- a/lib/Web/DevServer.php +++ b/lib/Web/DevServer.php @@ -193,19 +193,32 @@ public static function handleConnection($conn, string $docroot, callable $handle */ public static function readRequest($conn): ?array { - $lines = ''; + $buf = ''; while (!feof($conn)) { - $chunk = fgets($conn); - if (false === $chunk) { + $chunk = fread($conn, 8192); + if (false === $chunk || '' === $chunk) { break; } - $lines .= $chunk; - if ("\r\n" === $chunk) { + $buf .= $chunk; + if (str_contains($buf, "\r\n\r\n") || str_contains($buf, "\n\n")) { break; } } - if (!preg_match('#^(\S+)\s+(\S+)\s+(HTTP/\S+)#', $lines, $m)) { + $headerEnd = strpos($buf, "\r\n\r\n"); + $sepLen = 4; + if (false === $headerEnd) { + $headerEnd = strpos($buf, "\n\n"); + $sepLen = 2; + } + if (false === $headerEnd) { + return null; + } + + $headerBlock = substr($buf, 0, $headerEnd); + $body = substr($buf, $headerEnd + $sepLen); + + if (!preg_match('#^(\S+)\s+(\S+)\s+(HTTP/\S+)#', $headerBlock, $m)) { return null; } @@ -219,7 +232,7 @@ public static function readRequest($conn): ?array } $headers = []; - foreach (explode("\r\n", $lines) as $line) { + foreach (preg_split("/\r\n|\n/", $headerBlock) as $line) { if ('' === $line || false === strpos($line, ':')) { continue; } @@ -227,11 +240,17 @@ public static function readRequest($conn): ?array $headers[strtolower(trim($name))] = trim($value); } - $body = ''; if (isset($headers['content-length'])) { $len = (int) $headers['content-length']; while (strlen($body) < $len && !feof($conn)) { - $body .= fread($conn, $len - strlen($body)); + $chunk = fread($conn, $len - strlen($body)); + if (false === $chunk || '' === $chunk) { + break; + } + $body .= $chunk; + } + if (strlen($body) > $len) { + $body = substr($body, 0, $len); } } diff --git a/lib/Web/Superglobals.php b/lib/Web/Superglobals.php index 98831beca11..7ea3b02c00c 100644 --- a/lib/Web/Superglobals.php +++ b/lib/Web/Superglobals.php @@ -72,6 +72,7 @@ public static function populateFromEnvironment( ); self::populateServer($context, $queryString, $postBody); self::populateRequest($context); + self::$activeContext = null; } /** diff --git a/test/real/ServeAotTest.php b/test/real/ServeAotTest.php index 20bc20cc7ef..81145d84801 100644 --- a/test/real/ServeAotTest.php +++ b/test/real/ServeAotTest.php @@ -162,6 +162,48 @@ public function testServeAotPopulatesContentLengthOnPost(): void @rmdir($binaryDir); } + public function testServeAotPostFormUrlencoded(): void + { + $docroot = $this->makeDocroot([ + 'form.php' => <<<'PHP' +assertTrue(mkdir($binaryDir)); + $binary = $binaryDir.'/app'; + $this->compileExample($docroot.'/form.php', $binary); + $response = $this->httpPostAot($docroot, $binary, '/form.php', 'name=Alice'); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString('name=Alice', $response); + @unlink($binary); + @rmdir($binaryDir); + } + + public function testServeAotHttpResponseCode404SetsStatusLine(): void + { + $docroot = $this->makeDocroot([ + 'notfound.php' => <<<'PHP' +assertTrue(mkdir($binaryDir)); + $binary = $binaryDir.'/app'; + $this->compileExample($docroot.'/notfound.php', $binary); + $response = $this->httpGetAot($docroot, $binary, '/notfound.php'); + $this->assertStringContainsString('HTTP/1.1 404', $response); + $this->assertStringContainsString('missing', $response); + @unlink($binary); + @rmdir($binaryDir); + } + public function testServeAotHttpResponseCode405SetsStatusLine(): void { $docroot = $this->makeDocroot([ diff --git a/test/real/ServeTest.php b/test/real/ServeTest.php index c5b3bc34b62..489e4bd18d3 100644 --- a/test/real/ServeTest.php +++ b/test/real/ServeTest.php @@ -249,6 +249,20 @@ public function testPopulatesContentLengthOnPost(): void $this->assertStringContainsString('12', $response); } + public function testPostFormUrlencoded(): void + { + $docroot = $this->makeDocroot([ + 'form.php' => <<<'PHP' +httpPost($docroot, '/form.php', 'name=Alice'); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString('name=Alice', $this->responseBody($response)); + } + /** * @param array $extraEnv * @param list $extraRequestHeaders diff --git a/test/unit/VM/HashTableTest.php b/test/unit/VM/HashTableTest.php index bc5074dd4bd..b154ee47618 100755 --- a/test/unit/VM/HashTableTest.php +++ b/test/unit/VM/HashTableTest.php @@ -143,6 +143,33 @@ public function testFindOnUninitializedReturnsNull(): void $this->assertNull($ht->findIndex(0)); } + /** Regression: rehash must chain buckets (issue #248 POST / $_SERVER population). */ + public function testAddManyStringKeysIncludingContentLength(): void + { + $ht = new HashTable(); + $keys = [ + 'REQUEST_METHOD' => 'POST', + 'QUERY_STRING' => '', + 'SCRIPT_NAME' => '/index.php', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/index.php', + 'GATEWAY_INTERFACE' => 'CGI/1.1', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'SERVER_SOFTWARE' => 'PHP-Compiler-VM', + 'CONTENT_LENGTH' => '0', + ]; + foreach ($keys as $name => $value) { + $var = new Variable(Variable::TYPE_STRING); + $var->string($value); + $this->assertNotNull($ht->add($name, $var), 'add failed for '.$name); + } + foreach ($keys as $name => $value) { + $found = $ht->find($name); + $this->assertNotNull($found, 'find failed for '.$name); + $this->assertSame($value, $found->resolveIndirect()->toString()); + } + } + private function int(int $value): Variable { $var = new Variable(); diff --git a/test/unit/Web/DevServerHeadersTest.php b/test/unit/Web/DevServerHeadersTest.php index 5403ccc9eb6..442c684160e 100644 --- a/test/unit/Web/DevServerHeadersTest.php +++ b/test/unit/Web/DevServerHeadersTest.php @@ -97,4 +97,31 @@ public function testParsePeerAddressRejectsInvalid(): void $this->assertNull(DevServer::parsePeerAddress('no-port')); $this->assertNull(DevServer::parsePeerAddress('[::1]')); } + + public function testReadRequestPostBodyWithoutTrailingNewline(): void + { + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + if (false === $pair) { + $this->markTestSkipped('stream_socket_pair unavailable'); + } + [$server, $client] = $pair; + $raw = "POST /form.php HTTP/1.1\r\n" + ."Host: 127.0.0.1\r\n" + ."Content-Type: application/x-www-form-urlencoded\r\n" + ."Content-Length: 10\r\n" + ."Connection: close\r\n\r\n" + .'name=Alice'; + fwrite($client, $raw); + stream_socket_shutdown($client, STREAM_SHUT_WR); + fclose($client); + + $parsed = DevServer::readRequest($server); + fclose($server); + + $this->assertNotNull($parsed); + $this->assertSame('POST', $parsed[0]); + $this->assertSame('/form.php', $parsed[1]); + $this->assertSame('name=Alice', $parsed[4]); + $this->assertSame('10', $parsed[3]['content-length'] ?? null); + } }