From 88cf76ea840abe7476fc6dd56ac3c0c4cc29b9ae Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:45:39 +0200 Subject: [PATCH 1/6] Implement GH-19249: http context - allow content to be a stream/resource This may be useful for sending large chunks of data. --- UPGRADING | 1 + ext/standard/http_fopen_wrapper.c | 97 ++++++++++++++++--- .../tests/http/gh19249_custom_stream.phpt | 46 +++++++++ .../tests/http/gh19249_memory_stream.phpt | 34 +++++++ .../tests/http/gh19249_no_stream.phpt | 41 ++++++++ 5 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 ext/standard/tests/http/gh19249_custom_stream.phpt create mode 100644 ext/standard/tests/http/gh19249_memory_stream.phpt create mode 100644 ext/standard/tests/http/gh19249_no_stream.phpt diff --git a/UPGRADING b/UPGRADING index 2646da15b0a6f..1511e02ff1ffd 100644 --- a/UPGRADING +++ b/UPGRADING @@ -263,6 +263,7 @@ PHP 8.5 UPGRADE NOTES and the function returns false. Previously, these errors were silently ignored. This change affects only the sendmail transport. . getimagesize() now supports HEIF/HEIC images. + . The "http" stream context's "content" field now supports streams. - Standard: . getimagesize() now supports SVG images when ext-libxml is also loaded. diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c index 9fefe153622fc..bf4ff9aa1bf86 100644 --- a/ext/standard/http_fopen_wrapper.c +++ b/ext/standard/http_fopen_wrapper.c @@ -352,6 +352,43 @@ static zend_string *php_stream_http_response_headers_parse(php_stream_wrapper *w return NULL; } +static bool php_stream_unwrap_content(php_stream_context *context, zend_string **str_out, php_stream **stream_out) +{ + zval *content = php_stream_context_get_option(context, "http", "content"); + if (content) { + if (Z_TYPE_P(content) == IS_STRING && Z_STRLEN_P(content) > 0) { + *str_out = Z_STR_P(content); + return true; + } else if (Z_TYPE_P(content) == IS_RESOURCE) { + if ((php_stream_from_zval_no_verify(*stream_out, content))) { + return true; + } + } + } + + return false; +} + +static bool php_stream_append_content_length(smart_str *req_buf, zend_string *content_str, php_stream *content_stream) +{ + smart_str_appends(req_buf, "Content-Length: "); + if (content_str) { + smart_str_append_unsigned(req_buf, ZSTR_LEN(content_str)); + } else { + zend_off_t current_position = php_stream_tell(content_stream); + if (php_stream_seek(content_stream, 0, SEEK_END) < 0) { + return false; + } + zend_off_t end_position = php_stream_tell(content_stream); + if (php_stream_seek(content_stream, current_position, SEEK_SET) < 0) { + return false; + } + smart_str_append_unsigned(req_buf, end_position - current_position); + } + smart_str_appends(req_buf, "\r\n"); + return true; +} + static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, const char *path, const char *mode, int options, zend_string **opened_path, php_stream_context *context, int redirect_max, int flags, @@ -832,6 +869,9 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, } } + zend_string *content_str = NULL; + php_stream *content_stream = NULL; + if (user_headers) { /* A bit weird, but some servers require that Content-Length be sent prior to Content-Type for POST * see bug #44603 for details. Since Content-Type maybe part of user's headers we need to do this check first. @@ -840,42 +880,71 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, (header_init || redirect_keep_method) && context && !(have_header & HTTP_HEADER_CONTENT_LENGTH) && - (tmpzval = php_stream_context_get_option(context, "http", "content")) != NULL && - Z_TYPE_P(tmpzval) == IS_STRING && Z_STRLEN_P(tmpzval) > 0 + php_stream_unwrap_content(context, &content_str, &content_stream) ) { - smart_str_appends(&req_buf, "Content-Length: "); - smart_str_append_unsigned(&req_buf, Z_STRLEN_P(tmpzval)); - smart_str_appends(&req_buf, "\r\n"); + if (!php_stream_append_content_length(&req_buf, content_str, content_stream)) { + php_stream_close(stream); + stream = NULL; + efree(user_headers); + php_stream_wrapper_log_error(wrapper, options, "Unable to determine length of \"content\" stream!"); + goto out; + } have_header |= HTTP_HEADER_CONTENT_LENGTH; } smart_str_appends(&req_buf, user_headers); smart_str_appends(&req_buf, "\r\n"); efree(user_headers); + + /* php_stream_unwrap_content() may throw a TypeError for non-stream resources */ + if (UNEXPECTED(EG(exception))) { + php_stream_close(stream); + stream = NULL; + goto out; + } } /* Request content, such as for POST requests */ if ((header_init || redirect_keep_method) && context && - (tmpzval = php_stream_context_get_option(context, "http", "content")) != NULL && - Z_TYPE_P(tmpzval) == IS_STRING && Z_STRLEN_P(tmpzval) > 0) { + (content_str || content_stream || php_stream_unwrap_content(context, &content_str, &content_stream))) { if (!(have_header & HTTP_HEADER_CONTENT_LENGTH)) { - smart_str_appends(&req_buf, "Content-Length: "); - smart_str_append_unsigned(&req_buf, Z_STRLEN_P(tmpzval)); - smart_str_appends(&req_buf, "\r\n"); + if (!php_stream_append_content_length(&req_buf, content_str, content_stream)) { + php_stream_close(stream); + stream = NULL; + php_stream_wrapper_log_error(wrapper, options, "Unable to determine length of \"content\" stream!"); + goto out; + } } if (!(have_header & HTTP_HEADER_TYPE)) { smart_str_appends(&req_buf, "Content-Type: application/x-www-form-urlencoded\r\n"); php_error_docref(NULL, E_NOTICE, "Content-type not specified assuming application/x-www-form-urlencoded"); } smart_str_appends(&req_buf, "\r\n"); - smart_str_appendl(&req_buf, Z_STRVAL_P(tmpzval), Z_STRLEN_P(tmpzval)); + if (content_str) { + smart_str_append(&req_buf, content_str); + php_stream_write(stream, ZSTR_VAL(req_buf.s), ZSTR_LEN(req_buf.s)); + } else { + php_stream_write(stream, ZSTR_VAL(req_buf.s), ZSTR_LEN(req_buf.s)); + + if (SUCCESS != php_stream_copy_to_stream_ex(content_stream, stream, PHP_STREAM_COPY_ALL, NULL)) { + php_stream_close(stream); + stream = NULL; + php_stream_wrapper_log_error(wrapper, options, "Unable to copy \"content\" stream!"); + goto out; + } + } } else { + /* php_stream_unwrap_content() may throw a TypeError for non-stream resources */ + if (UNEXPECTED(EG(exception))) { + php_stream_close(stream); + stream = NULL; + goto out; + } + smart_str_appends(&req_buf, "\r\n"); + php_stream_write(stream, ZSTR_VAL(req_buf.s), ZSTR_LEN(req_buf.s)); } - /* send it */ - php_stream_write(stream, ZSTR_VAL(req_buf.s), ZSTR_LEN(req_buf.s)); - if (Z_ISUNDEF_P(response_header)) { array_init(response_header); } diff --git a/ext/standard/tests/http/gh19249_custom_stream.phpt b/ext/standard/tests/http/gh19249_custom_stream.phpt new file mode 100644 index 0000000000000..3218fb0f827b7 --- /dev/null +++ b/ext/standard/tests/http/gh19249_custom_stream.phpt @@ -0,0 +1,46 @@ +--TEST-- +GH-19249 (http context - allow content to be a stream/resource) - custom stream +--INI-- +allow_url_fopen=1 +--CONFLICTS-- +server +--FILE-- + [ + 'method' => 'POST', + 'header' => [ + 'Content-type: application/x-www-form-urlencoded', + ], + 'content' => $postData, + ] +])); +?> +--EXPECTF-- +Warning: file_get_contents(): Stream does not support seeking in %s on line %d + +Warning: file_get_contents(%s): Failed to open stream: Unable to determine length of "content" stream! in %s on line %d diff --git a/ext/standard/tests/http/gh19249_memory_stream.phpt b/ext/standard/tests/http/gh19249_memory_stream.phpt new file mode 100644 index 0000000000000..35a9d4af36467 --- /dev/null +++ b/ext/standard/tests/http/gh19249_memory_stream.phpt @@ -0,0 +1,34 @@ +--TEST-- +GH-19249 (http context - allow content to be a stream/resource) - memory stream +--INI-- +allow_url_fopen=1 +--CONFLICTS-- +server +--FILE-- + [ + 'method' => 'POST', + 'header' => [ + 'Content-type: application/x-www-form-urlencoded', + ], + 'content' => $postData, + ] +])); +?> +--EXPECT-- +string(1) "3" +c=d diff --git a/ext/standard/tests/http/gh19249_no_stream.phpt b/ext/standard/tests/http/gh19249_no_stream.phpt new file mode 100644 index 0000000000000..addc6f1f3ca22 --- /dev/null +++ b/ext/standard/tests/http/gh19249_no_stream.phpt @@ -0,0 +1,41 @@ +--TEST-- +GH-19249 (http context - allow content to be a stream/resource) - no stream +--INI-- +allow_url_fopen=1 +--CONFLICTS-- +server +--FILE-- + [ + 'Content-type: application/x-www-form-urlencoded', + ], +]; + +foreach ($headers as $header) { + try { + file_get_contents("http://" . PHP_CLI_SERVER_ADDRESS . "/", false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + ...$header, + 'content' => $postData, + ] + ])); + } catch (TypeError $e) { + echo $e->getMessage(), "\n"; + } +} + +proc_close($postData); +?> +--EXPECT-- +file_get_contents(): supplied resource is not a valid stream resource +file_get_contents(): supplied resource is not a valid stream resource From ddacfb26a11479e75b9dfb20015349f2c33ccd4f Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:59:29 +0200 Subject: [PATCH 2/6] Make error less confusing --- ext/standard/http_fopen_wrapper.c | 7 ++++++- ext/standard/tests/http/gh19249_no_stream.phpt | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c index bf4ff9aa1bf86..c20ac651d948e 100644 --- a/ext/standard/http_fopen_wrapper.c +++ b/ext/standard/http_fopen_wrapper.c @@ -360,8 +360,13 @@ static bool php_stream_unwrap_content(php_stream_context *context, zend_string * *str_out = Z_STR_P(content); return true; } else if (Z_TYPE_P(content) == IS_RESOURCE) { - if ((php_stream_from_zval_no_verify(*stream_out, content))) { + *stream_out = php_stream_from_zval_no_verify_no_error(content); + if (*stream_out) { return true; + } else { + const char *space; + const char *class_name = get_active_class_name(&space); + zend_type_error("%s%s%s(): \"content\" resource is not a valid stream resource", class_name, space, get_active_function_name()); } } } diff --git a/ext/standard/tests/http/gh19249_no_stream.phpt b/ext/standard/tests/http/gh19249_no_stream.phpt index addc6f1f3ca22..759f570ec72ad 100644 --- a/ext/standard/tests/http/gh19249_no_stream.phpt +++ b/ext/standard/tests/http/gh19249_no_stream.phpt @@ -37,5 +37,5 @@ foreach ($headers as $header) { proc_close($postData); ?> --EXPECT-- -file_get_contents(): supplied resource is not a valid stream resource -file_get_contents(): supplied resource is not a valid stream resource +file_get_contents(): "content" resource is not a valid stream resource +file_get_contents(): "content" resource is not a valid stream resource From f3d56e79ec982e06d08e27dc394486e7b4016beb Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:40:59 +0200 Subject: [PATCH 3/6] Handle non-seekable streams --- ext/standard/http_fopen_wrapper.c | 43 +++++++++++++------ .../tests/http/gh19249_custom_stream.phpt | 15 ++++++- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/ext/standard/http_fopen_wrapper.c b/ext/standard/http_fopen_wrapper.c index c20ac651d948e..a9a14d7751199 100644 --- a/ext/standard/http_fopen_wrapper.c +++ b/ext/standard/http_fopen_wrapper.c @@ -362,6 +362,7 @@ static bool php_stream_unwrap_content(php_stream_context *context, zend_string * } else if (Z_TYPE_P(content) == IS_RESOURCE) { *stream_out = php_stream_from_zval_no_verify_no_error(content); if (*stream_out) { + /* Note: at this point we don't know whether the stream is seekable, we can only know for sure by trying. */ return true; } else { const char *space; @@ -374,21 +375,29 @@ static bool php_stream_unwrap_content(php_stream_context *context, zend_string * return false; } -static bool php_stream_append_content_length(smart_str *req_buf, zend_string *content_str, php_stream *content_stream) +static bool php_stream_append_content_length(smart_str *req_buf, zend_string **content_str, php_stream **content_stream, bool *content_str_tmp) { smart_str_appends(req_buf, "Content-Length: "); - if (content_str) { - smart_str_append_unsigned(req_buf, ZSTR_LEN(content_str)); + if (*content_str) { + smart_str_append_unsigned(req_buf, ZSTR_LEN(*content_str)); } else { - zend_off_t current_position = php_stream_tell(content_stream); - if (php_stream_seek(content_stream, 0, SEEK_END) < 0) { - return false; - } - zend_off_t end_position = php_stream_tell(content_stream); - if (php_stream_seek(content_stream, current_position, SEEK_SET) < 0) { - return false; + zend_off_t current_position = php_stream_tell(*content_stream); + if (php_stream_seek(*content_stream, 0, SEEK_END) < 0) { + *content_str = php_stream_copy_to_mem(*content_stream, PHP_STREAM_COPY_ALL, false); + *content_stream = NULL; + if (*content_str) { + *content_str_tmp = true; + smart_str_append_unsigned(req_buf, ZSTR_LEN(*content_str)); + } else { + return false; + } + } else { + zend_off_t end_position = php_stream_tell(*content_stream); + if (php_stream_seek(*content_stream, current_position, SEEK_SET) < 0) { + return false; + } + smart_str_append_unsigned(req_buf, end_position - current_position); } - smart_str_append_unsigned(req_buf, end_position - current_position); } smart_str_appends(req_buf, "\r\n"); return true; @@ -876,6 +885,7 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, zend_string *content_str = NULL; php_stream *content_stream = NULL; + bool content_str_tmp = false; if (user_headers) { /* A bit weird, but some servers require that Content-Length be sent prior to Content-Type for POST @@ -887,7 +897,7 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, !(have_header & HTTP_HEADER_CONTENT_LENGTH) && php_stream_unwrap_content(context, &content_str, &content_stream) ) { - if (!php_stream_append_content_length(&req_buf, content_str, content_stream)) { + if (!php_stream_append_content_length(&req_buf, &content_str, &content_stream, &content_str_tmp)) { php_stream_close(stream); stream = NULL; efree(user_headers); @@ -903,6 +913,9 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, /* php_stream_unwrap_content() may throw a TypeError for non-stream resources */ if (UNEXPECTED(EG(exception))) { + if (content_str_tmp) { + zend_string_efree(content_str); + } php_stream_close(stream); stream = NULL; goto out; @@ -913,7 +926,7 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, if ((header_init || redirect_keep_method) && context && (content_str || content_stream || php_stream_unwrap_content(context, &content_str, &content_stream))) { if (!(have_header & HTTP_HEADER_CONTENT_LENGTH)) { - if (!php_stream_append_content_length(&req_buf, content_str, content_stream)) { + if (!php_stream_append_content_length(&req_buf, &content_str, &content_stream, &content_str_tmp)) { php_stream_close(stream); stream = NULL; php_stream_wrapper_log_error(wrapper, options, "Unable to determine length of \"content\" stream!"); @@ -928,6 +941,10 @@ static php_stream *php_stream_url_wrap_http_ex(php_stream_wrapper *wrapper, if (content_str) { smart_str_append(&req_buf, content_str); php_stream_write(stream, ZSTR_VAL(req_buf.s), ZSTR_LEN(req_buf.s)); + + if (content_str_tmp) { + zend_string_efree(content_str); + } } else { php_stream_write(stream, ZSTR_VAL(req_buf.s), ZSTR_LEN(req_buf.s)); diff --git a/ext/standard/tests/http/gh19249_custom_stream.phpt b/ext/standard/tests/http/gh19249_custom_stream.phpt index 3218fb0f827b7..cfcc9c4ffdbe8 100644 --- a/ext/standard/tests/http/gh19249_custom_stream.phpt +++ b/ext/standard/tests/http/gh19249_custom_stream.phpt @@ -8,13 +8,22 @@ server counter++; + if ($this->stream_eof()) { + return false; + } + return "test"; + } + + public function stream_eof(): bool { + return $this->counter == 2; } } @@ -43,4 +52,6 @@ echo file_get_contents("http://" . PHP_CLI_SERVER_ADDRESS . "/", false, stream_c --EXPECTF-- Warning: file_get_contents(): Stream does not support seeking in %s on line %d -Warning: file_get_contents(%s): Failed to open stream: Unable to determine length of "content" stream! in %s on line %d +Warning: file_get_contents(): MyStream::stream_stat is not implemented! in %s on line %d +string(1) "4" +test From 55ca3776f909ba1fa4a22ed19655d255bef5c603 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:38:29 +0200 Subject: [PATCH 4/6] Windows --- ext/standard/tests/http/gh19249_no_stream.phpt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ext/standard/tests/http/gh19249_no_stream.phpt b/ext/standard/tests/http/gh19249_no_stream.phpt index 759f570ec72ad..12dc42c111506 100644 --- a/ext/standard/tests/http/gh19249_no_stream.phpt +++ b/ext/standard/tests/http/gh19249_no_stream.phpt @@ -36,6 +36,7 @@ foreach ($headers as $header) { proc_close($postData); ?> ---EXPECT-- +--EXPECTF-- +%A file_get_contents(): "content" resource is not a valid stream resource file_get_contents(): "content" resource is not a valid stream resource From 4300417b1cb8c6fc3ac5a296edfdf8be1170bdfc Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:09:40 +0200 Subject: [PATCH 5/6] fix --- ext/standard/tests/http/gh19249_no_stream.phpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/standard/tests/http/gh19249_no_stream.phpt b/ext/standard/tests/http/gh19249_no_stream.phpt index 12dc42c111506..c7b0f9051f157 100644 --- a/ext/standard/tests/http/gh19249_no_stream.phpt +++ b/ext/standard/tests/http/gh19249_no_stream.phpt @@ -11,7 +11,7 @@ $serverCode = ''; include __DIR__."/../../../../sapi/cli/tests/php_cli_server.inc"; php_cli_server_start($serverCode, null, []); -$postData = proc_open("echo", [], $pipes); +$postData = proc_open("echo Windows sucks", [], $pipes); $headers = [ [], From 45582d7bba5b69227421f75d88689e0616dbc6f7 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:13:06 +0200 Subject: [PATCH 6/6] attempt 2 to make both Linux and Windows happy --- ext/standard/tests/http/gh19249_no_stream.phpt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ext/standard/tests/http/gh19249_no_stream.phpt b/ext/standard/tests/http/gh19249_no_stream.phpt index c7b0f9051f157..94ba521901d6a 100644 --- a/ext/standard/tests/http/gh19249_no_stream.phpt +++ b/ext/standard/tests/http/gh19249_no_stream.phpt @@ -11,7 +11,7 @@ $serverCode = ''; include __DIR__."/../../../../sapi/cli/tests/php_cli_server.inc"; php_cli_server_start($serverCode, null, []); -$postData = proc_open("echo Windows sucks", [], $pipes); +$postData = proc_open("echo", [0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes); $headers = [ [], @@ -36,7 +36,6 @@ foreach ($headers as $header) { proc_close($postData); ?> ---EXPECTF-- -%A +--EXPECT-- file_get_contents(): "content" resource is not a valid stream resource file_get_contents(): "content" resource is not a valid stream resource