diff --git a/ext/standard/BuiltinIntrospectionPolicy.php b/ext/standard/BuiltinIntrospectionPolicy.php index e54834644cf..33be09af9d5 100644 --- a/ext/standard/BuiltinIntrospectionPolicy.php +++ b/ext/standard/BuiltinIntrospectionPolicy.php @@ -51,6 +51,9 @@ public static function functionIsAdvertised(string $functionName): bool ], true)) { return CompilerVersion::advertisesHttpLastResponseHeaders(); } + if ('request_parse_body' === $lc) { + return CompilerVersion::advertisesRequestParseBody(); + } if ('stream_context_set_options' === $lc) { return CompilerVersion::advertisesStreamContextSetOptions(); } diff --git a/ext/standard/Module.php b/ext/standard/Module.php index 0a5fa617a32..8a094bb18f8 100755 --- a/ext/standard/Module.php +++ b/ext/standard/Module.php @@ -550,6 +550,7 @@ public function getFunctions(): array new urlencode(), new rawurlencode(), new http_build_query(), + ...(CompilerVersion::supportsRequestParseBody() ? [new request_parse_body()] : []), new parse_str(), new urldecode(), new rawurldecode(), diff --git a/ext/standard/RequestParseBodyEngine.php b/ext/standard/RequestParseBodyEngine.php new file mode 100644 index 00000000000..c22b982eedd --- /dev/null +++ b/ext/standard/RequestParseBodyEngine.php @@ -0,0 +1,314 @@ + */ + private const OPTION_KEYS = [ + 'max_file_uploads', + 'max_input_vars', + 'max_multipart_body_parts', + 'post_max_size', + 'upload_max_filesize', + ]; + + /** + * @param array|null $options + * + * @return array{0: array, 1: array} [$_POST, $_FILES] + */ + public static function parseFromEnvironment(?array $options = null): array + { + self::validateOptions($options); + + // Zend: body is a one-shot stream; subsequent parses return empty. + if (self::$consumed) { + return [[], []]; + } + self::$consumed = true; + + $mediaType = self::contentTypeMediaType(); + if ('' === $mediaType) { + throw new \RequestParseBodyException('RequestParseBodyException: Missing Content-Type header'); + } + + $body = self::readRequestBody(); + if ('' === $body) { + return [[], []]; + } + + if ('application/x-www-form-urlencoded' === $mediaType) { + return [ParseStrEngine::parse($body), []]; + } + if (str_starts_with($mediaType, 'multipart/form-data')) { + return self::parseMultipartFromEnvironment($body); + } + + throw new \RequestParseBodyException('RequestParseBodyException: Unsupported Content-Type'); + } + + /** + * @param array|null $options + */ + private static function validateOptions(?array $options): void + { + if (null === $options) { + return; + } + foreach ($options as $key => $value) { + if (!is_string($key) || !in_array($key, self::OPTION_KEYS, true)) { + throw new \ValueError('request_parse_body(): Argument #1 ($options) contains invalid keys'); + } + if (!is_int($value) && !is_string($value)) { + throw new \ValueError('request_parse_body(): Argument #1 ($options) contains invalid values'); + } + } + } + + /** + * @return array{0: array, 1: array} + */ + private static function parseMultipartFromEnvironment(string $body): array + { + $boundary = self::extractBoundaryFromEnvironment(); + if (null === $boundary) { + throw new \RequestParseBodyException('RequestParseBodyException: Invalid multipart boundary'); + } + + $post = []; + $files = []; + + $body = str_replace("\r\n", "\n", str_replace("\r", "\n", $body)); + $delimiter = '--'.$boundary; + $segments = explode($delimiter, $body); + array_shift($segments); + foreach ($segments as $segment) { + $segment = ltrim($segment, "\r\n"); + if ('' === $segment || str_starts_with($segment, '--')) { + continue; + } + if (str_ends_with($segment, '--')) { + $segment = substr($segment, 0, -2); + } + $segment = rtrim($segment, "\r\n"); + $part = self::splitPart($segment); + if (null === $part) { + continue; + } + [$rawHeaders, $content] = $part; + $disposition = self::headerValue($rawHeaders, 'Content-Disposition'); + if (null === $disposition) { + continue; + } + $fieldName = self::paramValue($disposition, 'name'); + if (null === $fieldName || '' === $fieldName) { + continue; + } + $filename = self::paramValue($disposition, 'filename'); + if (null !== $filename) { + $files[$fieldName] = self::buildFileEntry($filename, $rawHeaders, $content); + continue; + } + + $params = []; + parse_str($fieldName.'='.$content, $params); + $post = self::mergeArrays($post, $params); + } + + return [$post, $files]; + } + + /** + * @return array{name: string, type: string, tmp_name: string, error: int, size: int} + */ + private static function buildFileEntry(string $filename, string $rawHeaders, string $content): array + { + $tmp = UploadTemp::createTempFile(); + if (false === $tmp) { + return [ + 'name' => $filename, + 'type' => 'application/octet-stream', + 'tmp_name' => '', + 'error' => 1, + 'size' => 0, + ]; + } + if (false === file_put_contents($tmp, $content)) { + @unlink($tmp); + + return [ + 'name' => $filename, + 'type' => 'application/octet-stream', + 'tmp_name' => '', + 'error' => 1, + 'size' => 0, + ]; + } + + $partType = self::headerValue($rawHeaders, 'Content-Type'); + + return [ + 'name' => $filename, + 'type' => null !== $partType && '' !== $partType ? $partType : 'application/octet-stream', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => strlen($content), + ]; + } + + /** + * @return array{0: string, 1: string}|null + */ + private static function splitPart(string $segment): ?array + { + $lines = preg_split("/\r?\n/", $segment) ?: []; + $headerLines = []; + $contentLines = []; + $index = 0; + $lineCount = 0; + foreach ($lines as $_) { + ++$lineCount; + } + while ($index < $lineCount) { + if ('' === trim($lines[$index], "\r\n")) { + $peek = $index + 1; + while ($peek < $lineCount && '' === trim($lines[$peek], "\r\n")) { + ++$peek; + } + if ($peek < $lineCount && str_contains($lines[$peek], ':')) { + ++$index; + continue; + } + ++$index; + break; + } + $headerLines[] = $lines[$index]; + ++$index; + } + while ($index < $lineCount) { + $contentLines[] = $lines[$index]; + ++$index; + } + if ([] === $headerLines || [] === $contentLines) { + return null; + } + + return [implode("\n", $headerLines), trim(implode("\n", $contentLines), "\r\n")]; + } + + private static function extractBoundaryFromEnvironment(): ?string + { + $contentType = VmEnv::getenv('CONTENT_TYPE'); + if (false === $contentType || '' === $contentType) { + $contentType = VmEnv::getenv('HTTP_CONTENT_TYPE'); + } + if (false === $contentType || '' === $contentType) { + return null; + } + if (!preg_match('/boundary\s*=\s*(?:"([^"]+)"|([^\s;]+))/i', $contentType, $matches)) { + return null; + } + + return '' !== $matches[1] ? $matches[1] : $matches[2]; + } + + private static function contentTypeMediaType(): string + { + $contentType = VmEnv::getenv('CONTENT_TYPE'); + if (false === $contentType || '' === $contentType) { + $contentType = VmEnv::getenv('HTTP_CONTENT_TYPE'); + } + if (false === $contentType || '' === $contentType) { + return ''; + } + $contentType = strtolower(trim($contentType)); + $semi = strpos($contentType, ';'); + if (false !== $semi) { + $contentType = substr($contentType, 0, $semi); + } + + return trim($contentType); + } + + private static function readRequestBody(): string + { + $fromFile = VmEnv::getenv('REQUEST_BODY_FILE'); + if (false !== $fromFile && '' !== $fromFile && is_readable($fromFile)) { + $contents = file_get_contents($fromFile); + + return false === $contents ? '' : $contents; + } + $fromEnv = VmEnv::getenv('REQUEST_BODY'); + + return false === $fromEnv ? '' : $fromEnv; + } + + private static function headerValue(string $rawHeaders, string $name): ?string + { + foreach (preg_split("/\r?\n/", $rawHeaders) ?: [] as $line) { + $line = trim($line, "\r\n"); + if ('' === $line || !str_contains($line, ':')) { + continue; + } + [$headerName, $value] = explode(':', $line, 2); + if (0 === strcasecmp(trim($headerName), $name)) { + return trim($value, "\r\n "); + } + } + + return null; + } + + private static function paramValue(string $disposition, string $param): ?string + { + if (!preg_match('/'.preg_quote($param, '/').'\s*=\s*"([^"]*)"/i', $disposition, $matches)) { + return null; + } + + return $matches[1]; + } + + /** + * Merge parse_str-style arrays without reindexing numeric keys. + * + * @param array $base + * @param array $next + */ + private static function mergeArrays(array $base, array $next): array + { + foreach ($next as $k => $v) { + if (is_array($v) && isset($base[$k]) && is_array($base[$k])) { + $base[$k] = self::mergeArrays($base[$k], $v); + } else { + $base[$k] = $v; + } + } + + return $base; + } +} + +} + diff --git a/ext/standard/RequestParseBodyJitHelper.php b/ext/standard/RequestParseBodyJitHelper.php new file mode 100644 index 00000000000..25911830f48 --- /dev/null +++ b/ext/standard/RequestParseBodyJitHelper.php @@ -0,0 +1,19 @@ +calledArgs); + if (0 !== $argc && 1 !== $argc) { + throw new \ArgumentCountError('request_parse_body() expects at most 1 argument, '.$argc.' given'); + } + $options = null; + if (1 === $argc) { + $optVar = $frame->calledArgs[0]->resolveIndirect(); + if (Variable::TYPE_NULL !== $optVar->type) { + if (Variable::TYPE_ARRAY !== $optVar->type) { + throw new \TypeError('request_parse_body(): Argument #1 ($options) must be of type ?array'); + } + $options = VmHttpBuildQuery::export($optVar, $frame); + if (!is_array($options)) { + throw new \TypeError('request_parse_body(): Argument #1 ($options) must be of type ?array'); + } + } + } + + BuiltinExecute::writeReturn($frame, static function (Variable $ret) use ($options): void { + [$post, $files] = RequestParseBodyEngine::parseFromEnvironment($options); + $postHt = new HashTable(); + $filesHt = new HashTable(); + if (is_array($post)) { + VmParseStr::mergeInto($postHt, $post); + } + if (is_array($files)) { + VmParseStr::mergeInto($filesHt, $files); + } + $pair = new HashTable(); + $postVar = new Variable(Variable::TYPE_ARRAY); + $postVar->array($postHt); + $filesVar = new Variable(Variable::TYPE_ARRAY); + $filesVar->array($filesHt); + $pair->addIndex(0, $postVar); + $pair->addIndex(1, $filesVar); + $ret->array($pair); + }); + } + + public function call(Context $context, JITVariable ...$args): Value + { + $argc = \count($args); + if (0 !== $argc && 1 !== $argc) { + ExceptionBridge::emitArgumentCountError( + $context, + 'request_parse_body() expects at most 1 argument, '.$argc.' given' + ); + + return ArrayBuiltinHelper::emptyArray($context); + } + if (1 === $argc && JITVariable::TYPE_NULL !== $args[0]->type) { + ExceptionBridge::emitTypeError($context, 'request_parse_body(): Argument #1 ($options) must be of type ?array'); + + return ArrayBuiltinHelper::emptyArray($context); + } + + RequestParseBodyRuntime::ensureLinked($context); + + $postHt = HashTableHelper::alloc($context); + $filesHt = HashTableHelper::alloc($context); + $context->builder->call( + $context->lookupFunction('__compiler_request_parse_body'), + $postHt, + $filesHt + ); + + $result = HashTableHelper::alloc($context); + $i64 = $context->getTypeFromString('int64'); + $postVar = new \PHPCompiler\JIT\Variable($context, \PHPCompiler\JIT\Variable::TYPE_HASHTABLE, \PHPCompiler\JIT\Variable::KIND_VALUE, $postHt); + $filesVar = new \PHPCompiler\JIT\Variable($context, \PHPCompiler\JIT\Variable::TYPE_HASHTABLE, \PHPCompiler\JIT\Variable::KIND_VALUE, $filesHt); + HashTableHelper::setAtIndex( + $context, + $result, + $context->constantFromInteger(0, 'size_t'), + $postVar + ); + HashTableHelper::setAtIndex( + $context, + $result, + $context->constantFromInteger(1, 'size_t'), + $filesVar + ); + + return $result; + } +} + diff --git a/lib/CompilerVersion.php b/lib/CompilerVersion.php index 26d56cde3da..b1b1b74f516 100644 --- a/lib/CompilerVersion.php +++ b/lib/CompilerVersion.php @@ -1429,7 +1429,27 @@ public static function advertisesDateExceptionHierarchy(): bool */ public static function advertisesRequestParseBodyExceptionClass(): bool { - return version_compare(self::VERSION, '8.4.0', '>='); + return version_compare(self::languageProfileVersion(), '8.4.0', '>='); + } + + /** + * PHP 8.4+ request_parse_body() (ext/standard/http.c, issue #16927). + * + * Gated on stable 8.4.0 / PHP_COMPILER_PROFILE=8.4 so 8.4.0-dev reference profile matches Zend 8.2 phantom gate. + */ + public static function supportsRequestParseBody(): bool + { + return version_compare(self::languageProfileVersion(), '8.4.0', '>='); + } + + /** request_parse_body() visible to function_exists() — stable runtime or forward 8.4+ profile. */ + public static function advertisesRequestParseBody(): bool + { + if (version_compare(self::VERSION, '8.4.0', '>=')) { + return true; + } + + return self::supportsRequestParseBody(); } /** diff --git a/lib/JIT/Builtin/RequestParseBodyRuntime.php b/lib/JIT/Builtin/RequestParseBodyRuntime.php new file mode 100644 index 00000000000..59b0c5c2f95 --- /dev/null +++ b/lib/JIT/Builtin/RequestParseBodyRuntime.php @@ -0,0 +1,187 @@ + */ + private const COMPILED_HELPERS = [ + self::PARSE_INTO_NATIVE_HELPER, + ]; + + /** @var list */ + private const RUNTIME_FUNCTIONS = [ + '__compiler_request_parse_body', + ]; + + public static function ensureLinked(Context $context): void + { + self::implement($context); + } + + public static function implement(Context $context): void + { + if (self::allRuntimeFunctionsLinked($context)) { + self::registerLinkedRuntime($context); + + return; + } + + $savedBlock = null; + try { + $savedBlock = $context->builder->getInsertBlock(); + } catch (\Throwable) { + } + + self::ensureNativeHtInternalProxies($context); + JitVmHelperLink::ensureCompiled( + $context, + self::HELPER_PATH, + self::COMPILED_HELPERS, + '#16927' + ); + + self::implementIfMissing( + $context, + '__compiler_request_parse_body', + static function (Context $context, LlvmFunction $fn): void { + self::implementParseBridge($context, $fn, self::PARSE_INTO_NATIVE_HELPER); + } + ); + + self::registerLinkedRuntime($context); + + if (null !== $savedBlock) { + $context->builder->positionAtEnd($savedBlock); + } else { + $context->builder->clearInsertionPosition(); + } + } + + /** + * @param callable(Context, LlvmFunction): void $emit + */ + private static function implementIfMissing(Context $context, string $name, callable $emit): void + { + $probe = $context->module->getNamedFunction($name); + if (null !== $probe && 0 !== $probe->countBasicBlocks()) { + $context->registerFunction($name, $probe); + + return; + } + + $fn = null !== $probe ? $probe : self::declareFunction($context, $name); + $emit($context, $fn); + $context->registerFunction($name, $fn); + $context->builder->clearInsertionPosition(); + } + + private static function declareFunction(Context $context, string $name): LlvmFunction + { + $htPtr = $context->getTypeFromString('__hashtable__*'); + $void = $context->getTypeFromString('void'); + + return $context->module->addFunction( + $name, + $context->context->functionType($void, false, $htPtr, $htPtr) + ); + } + + private static function implementParseBridge(Context $context, LlvmFunction $fn, string $helperLogical): void + { + $entry = $fn->appendBasicBlock('request_parse_body_bridge_entry'); + $early = $fn->appendBasicBlock('request_parse_body_bridge_early'); + $work = $fn->appendBasicBlock('request_parse_body_bridge_work'); + $context->builder->positionAtEnd($entry); + + $post = $fn->getParam(0); + $files = $fn->getParam(1); + $htPtr = $context->getTypeFromString('__hashtable__*'); + $nullPost = $context->builder->icmp(Builder::INT_EQ, $post, $htPtr->constNull()); + $nullFiles = $context->builder->icmp(Builder::INT_EQ, $files, $htPtr->constNull()); + $anyNull = $context->builder->or($nullPost, $nullFiles); + $context->builder->branchIf($anyNull, $early, $work); + + $context->builder->positionAtEnd($early); + $context->builder->returnVoid(); + + $context->builder->positionAtEnd($work); + $helperFn = JitVmHelperLink::lookupCompiled($context, $helperLogical, '#16927'); + $postI64 = JitNestedHelperCoerce::ptrToI64($context, $post); + $filesI64 = JitNestedHelperCoerce::ptrToI64($context, $files); + $optionsNull = $helperFn->getParam(2)->typeOf()->constNull(); + $context->builder->call( + $helperFn, + $postI64, + $filesI64, + JitNestedHelperCoerce::coerceArgForHelper($context, $optionsNull, $helperFn->getParam(2)->typeOf()) + ); + $context->builder->returnVoid(); + } + + private static function registerLinkedRuntime(Context $context): void + { + foreach (self::RUNTIME_FUNCTIONS as $name) { + $fn = $context->module->getNamedFunction($name); + if (null === $fn || 0 === $fn->countBasicBlocks()) { + throw new \LogicException($name.' missing after RequestParseBodyRuntime bridge (#16927)'); + } + $context->registerFunction($name, $fn); + } + } + + private static function allRuntimeFunctionsLinked(Context $context): bool + { + foreach (self::RUNTIME_FUNCTIONS as $name) { + $fn = $context->module->getNamedFunction($name); + if (null === $fn || 0 === $fn->countBasicBlocks()) { + return false; + } + } + + return true; + } + + /** Register phpc_native_ht_* Internal JIT handlers before nested helper compile (#16927). */ + private static function ensureNativeHtInternalProxies(Context $context): void + { + $internals = [ + new phpc_native_ht_alloc(), + new phpc_native_ht_set_string_key(), + new phpc_native_ht_set_string_key_ht(), + new phpc_native_ht_set_string_at(), + new phpc_native_ht_set_hashtable_at(), + ]; + foreach ($internals as $internal) { + $lc = strtolower($internal->getName()); + $existing = $context->functionProxies[$lc] ?? null; + if (null === $existing || $existing instanceof \PHPCompiler\JIT\Call\ExternalMethod) { + $context->functionProxies[$lc] = $internal; + } + } + } +} + diff --git a/test/compliance/cases/stdlib/request_parse_body_multipart_profile_84.phpt b/test/compliance/cases/stdlib/request_parse_body_multipart_profile_84.phpt new file mode 100644 index 00000000000..42801fa4dc1 --- /dev/null +++ b/test/compliance/cases/stdlib/request_parse_body_multipart_profile_84.phpt @@ -0,0 +1,34 @@ +--TEST-- +stdlib PHP 8.4 profile — request_parse_body multipart parsing (ext/standard/http.c, #16927) +--ENV-- +PHP_COMPILER_PROFILE=8.4 +--FILE-- + +--EXPECT-- +hi +t.txt +text/plain +0 +7 +payload + diff --git a/test/compliance/cases/stdlib/request_parse_body_profile_84.phpt b/test/compliance/cases/stdlib/request_parse_body_profile_84.phpt new file mode 100644 index 00000000000..45c34aac560 --- /dev/null +++ b/test/compliance/cases/stdlib/request_parse_body_profile_84.phpt @@ -0,0 +1,15 @@ +--TEST-- +stdlib PHP 8.4 profile — request_parse_body() + RequestParseBodyException registered (#16927, ext/standard/http.c) +--ENV-- +PHP_COMPILER_PROFILE=8.4 +--FILE-- + +--EXPECT-- +true +true + diff --git a/test/compliance/cases/stdlib/request_parse_body_reference_profile.phpt b/test/compliance/cases/stdlib/request_parse_body_reference_profile.phpt new file mode 100644 index 00000000000..be68a498b39 --- /dev/null +++ b/test/compliance/cases/stdlib/request_parse_body_reference_profile.phpt @@ -0,0 +1,10 @@ +--TEST-- +stdlib request_parse_body absent on 8.2 reference profile (ext/standard/http.c) +--FILE-- + +--EXPECT-- +false + diff --git a/test/compliance/cases/stdlib/request_parse_body_urlencoded_profile_84.phpt b/test/compliance/cases/stdlib/request_parse_body_urlencoded_profile_84.phpt new file mode 100644 index 00000000000..fbc86bd9338 --- /dev/null +++ b/test/compliance/cases/stdlib/request_parse_body_urlencoded_profile_84.phpt @@ -0,0 +1,22 @@ +--TEST-- +stdlib PHP 8.4 profile — request_parse_body urlencoded parsing (ext/standard/http.c, #16927) +--ENV-- +PHP_COMPILER_PROFILE=8.4 +--FILE-- + +--EXPECT-- +array ( + 'a' => '1', + 'b' => 'two', +) +array ( +) + diff --git a/test/repro/maintainer_gap_request_parse_body_multipart_profile_84.php b/test/repro/maintainer_gap_request_parse_body_multipart_profile_84.php new file mode 100644 index 00000000000..3e794215e7a --- /dev/null +++ b/test/repro/maintainer_gap_request_parse_body_multipart_profile_84.php @@ -0,0 +1,25 @@ +