Commit 93fa5f3c882 for php.net

commit 93fa5f3c882e5b51342fa0674d25d5d29d7a0546
Author: Graham Campbell <hello@gjcampbell.co.uk>
Date:   Fri Jun 5 15:51:50 2026 +0100

    ext/curl: add CURLOPT_SEEKFUNCTION

diff --git a/NEWS b/NEWS
index 190c6c16a93..4be8d1e554c 100644
--- a/NEWS
+++ b/NEWS
@@ -14,6 +14,12 @@ PHP                                                                        NEWS
   . Fixed bug GH-22602 (gregoriantojd() and juliantojd() integer overflow with
     INT_MAX year). (arshidkv12)

+- Curl:
+  . Added CURLOPT_SEEKFUNCTION and the CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL
+    and CURL_SEEKFUNC_CANTSEEK constants, letting libcurl rewind a streamed
+    request body to resend it on a redirect, multi-pass authentication or a
+    retried reused connection. (GrahamCampbell)
+
 - Date:
   . Update timelib to 2022.17. (Derick)
   . Fixed bug GH-19803 (Parsing a string with a single white space does create
diff --git a/UPGRADING b/UPGRADING
index 031b6751d85..a809687d196 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -238,6 +238,12 @@ PHP 8.6 UPGRADE NOTES
     This value can also be obtained by passing CURLINFO_SIZE_DELIVERED as the
     $option parameter.
     Requires libcurl 8.20.0 or later.
+  . Added CURLOPT_SEEKFUNCTION to register a callback that repositions a
+    streamed request body so libcurl can rewind and resend it on a redirect,
+    multi-pass authentication, or a retried reused connection instead of
+    failing with CURLE_SEND_FAIL_REWIND. The callback receives the CurlHandle,
+    offset and origin, and must return one of CURL_SEEKFUNC_OK,
+    CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK.

 - Fileinfo:
   . finfo_file() now works with remote streams.
@@ -467,6 +473,10 @@ PHP 8.6 UPGRADE NOTES

 - Curl:
   . CURLINFO_SIZE_DELIVERED (libcurl >= 8.20.0).
+  . CURLOPT_SEEKFUNCTION.
+  . CURL_SEEKFUNC_OK.
+  . CURL_SEEKFUNC_FAIL.
+  . CURL_SEEKFUNC_CANTSEEK.

 - Sockets:
   . TCP_USER_TIMEOUT (Linux only).
diff --git a/ext/curl/curl.stub.php b/ext/curl/curl.stub.php
index d3bd58510e4..70e87cc9b14 100644
--- a/ext/curl/curl.stub.php
+++ b/ext/curl/curl.stub.php
@@ -343,6 +343,11 @@
  * @cvalue CURLOPT_RETURNTRANSFER
  */
 const CURLOPT_RETURNTRANSFER = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURLOPT_SEEKFUNCTION
+ */
+const CURLOPT_SEEKFUNCTION = UNKNOWN;
 /**
  * @var int
  * @cvalue CURLOPT_SHARE
@@ -1788,6 +1793,21 @@
  * @cvalue CURL_READFUNC_PAUSE
  */
 const CURL_READFUNC_PAUSE = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURL_SEEKFUNC_OK
+ */
+const CURL_SEEKFUNC_OK = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURL_SEEKFUNC_FAIL
+ */
+const CURL_SEEKFUNC_FAIL = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURL_SEEKFUNC_CANTSEEK
+ */
+const CURL_SEEKFUNC_CANTSEEK = UNKNOWN;
 /**
  * @var int
  * @cvalue CURL_WRITEFUNC_PAUSE
diff --git a/ext/curl/curl_arginfo.h b/ext/curl/curl_arginfo.h
index 6a05b73572c..f2929f60c4e 100644
Binary files a/ext/curl/curl_arginfo.h and b/ext/curl/curl_arginfo.h differ
diff --git a/ext/curl/curl_private.h b/ext/curl/curl_private.h
index 77b0628ee42..25cc37b6e45 100644
--- a/ext/curl/curl_private.h
+++ b/ext/curl/curl_private.h
@@ -74,6 +74,7 @@ typedef struct {
 	php_curl_write    *write_header;
 	php_curl_read     *read;
 	zval               std_err;
+	zend_fcall_info_cache seek;
 	zend_fcall_info_cache progress;
 	zend_fcall_info_cache xferinfo;
 	zend_fcall_info_cache fnmatch;
diff --git a/ext/curl/interface.c b/ext/curl/interface.c
index 09c7009068b..b7ec015abf6 100644
--- a/ext/curl/interface.c
+++ b/ext/curl/interface.c
@@ -456,6 +456,10 @@ static HashTable *curl_get_gc(zend_object *object, zval **table, int *n)
 		zend_get_gc_buffer_add_zval(gc_buffer, &curl->handlers.write_header->stream);
 	}

+	if (ZEND_FCC_INITIALIZED(curl->handlers.seek)) {
+		zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.seek);
+	}
+
 	if (ZEND_FCC_INITIALIZED(curl->handlers.progress)) {
 		zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.progress);
 	}
@@ -831,6 +835,52 @@ static size_t curl_read(char *data, size_t size, size_t nmemb, void *ctx)
 }
 /* }}} */

+/* {{{ curl_seek */
+static int curl_seek(void *clientp, curl_off_t offset, int origin)
+{
+	php_curl *ch = (php_curl *)clientp;
+	int rval = CURL_SEEKFUNC_CANTSEEK; /* safe default if unset or the callback misbehaves */
+
+#if PHP_CURL_DEBUG
+	fprintf(stderr, "curl_seek() called\n");
+	fprintf(stderr, "clientp = %x, offset = %ld, origin = %d\n", clientp, offset, origin);
+#endif
+	if (!ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
+		return rval;
+	}
+
+	zval args[3];
+	zval retval;
+
+	GC_ADDREF(&ch->std);
+	ZVAL_OBJ(&args[0], &ch->std);
+	ZVAL_LONG(&args[1], offset);
+	ZVAL_LONG(&args[2], origin);
+
+	ch->in_callback = true;
+	zend_call_known_fcc(&ch->handlers.seek, &retval, /* param_count */ 3, args, /* named_params */ NULL);
+	ch->in_callback = false;
+
+	if (!Z_ISUNDEF(retval)) {
+		_php_curl_verify_handlers(ch, /* reporterror */ true);
+		if (Z_TYPE(retval) == IS_LONG) {
+			zend_long retval_long = Z_LVAL(retval);
+			if (retval_long == CURL_SEEKFUNC_OK || retval_long == CURL_SEEKFUNC_FAIL || retval_long == CURL_SEEKFUNC_CANTSEEK) {
+				rval = retval_long;
+			} else {
+				zend_value_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK");
+			}
+		} else {
+			zend_type_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK");
+		}
+		zval_ptr_dtor(&retval);
+	}
+
+	zval_ptr_dtor(&args[0]);
+	return rval;
+}
+/* }}} */
+
 /* {{{ curl_write_header */
 static size_t curl_write_header(char *data, size_t size, size_t nmemb, void *ctx)
 {
@@ -1038,6 +1088,7 @@ void init_curl_handle(php_curl *ch)
 	ch->handlers.write = ecalloc(1, sizeof(php_curl_write));
 	ch->handlers.write_header = ecalloc(1, sizeof(php_curl_write));
 	ch->handlers.read = ecalloc(1, sizeof(php_curl_read));
+	ch->handlers.seek = empty_fcall_info_cache;
 	ch->handlers.progress = empty_fcall_info_cache;
 	ch->handlers.xferinfo = empty_fcall_info_cache;
 	ch->handlers.fnmatch = empty_fcall_info_cache;
@@ -1208,6 +1259,7 @@ void _php_setup_easy_copy_handlers(php_curl *ch, php_curl *source)
 	curl_easy_setopt(ch->cp, CURLOPT_WRITEHEADER,       (void *) ch);
 	curl_easy_setopt(ch->cp, CURLOPT_DEBUGDATA,         (void *) ch);

+	php_curl_copy_fcc_with_option(ch, CURLOPT_SEEKDATA, &ch->handlers.seek, &source->handlers.seek);
 	php_curl_copy_fcc_with_option(ch, CURLOPT_PROGRESSDATA, &ch->handlers.progress, &source->handlers.progress);
 	php_curl_copy_fcc_with_option(ch, CURLOPT_XFERINFODATA, &ch->handlers.xferinfo, &source->handlers.xferinfo);
 	php_curl_copy_fcc_with_option(ch, CURLOPT_FNMATCH_DATA, &ch->handlers.fnmatch, &source->handlers.fnmatch);
@@ -1577,6 +1629,7 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue
 		HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_HEADER, write_header, PHP_CURL_IGNORE);
 		HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_READ, read, PHP_CURL_DIRECT);

+		HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_SEEK, handlers.seek, curl_seek);
 		HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_PROGRESS, handlers.progress, curl_progress);
 		HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_XFERINFO, handlers.xferinfo, curl_xferinfo);
 		HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_FNMATCH_, handlers.fnmatch, curl_fnmatch);
@@ -2794,6 +2847,9 @@ static void curl_free_obj(zend_object *object)
 	efree(ch->handlers.write_header);
 	efree(ch->handlers.read);

+	if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
+		zend_fcc_dtor(&ch->handlers.seek);
+	}
 	if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) {
 		zend_fcc_dtor(&ch->handlers.progress);
 	}
@@ -2878,6 +2934,10 @@ static void _php_curl_reset_handlers(php_curl *ch)
 		ZVAL_UNDEF(&ch->handlers.std_err);
 	}

+	if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
+		zend_fcc_dtor(&ch->handlers.seek);
+	}
+
 	if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) {
 		zend_fcc_dtor(&ch->handlers.progress);
 	}
diff --git a/ext/curl/tests/curl_copy_handle_seek.phpt b/ext/curl/tests/curl_copy_handle_seek.phpt
new file mode 100644
index 00000000000..c2a2f37bbe1
--- /dev/null
+++ b/ext/curl/tests/curl_copy_handle_seek.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Test curl_copy_handle() with CURLOPT_SEEKFUNCTION
+--EXTENSIONS--
+curl
+--FILE--
+<?php
+include 'server.inc';
+$host = curl_cli_server_start();
+
+$body = 'Hello cURL seek!';
+$offset = 0;
+$seekCalls = 0;
+
+$ch = curl_init("{$host}/get.inc?test=redirect");
+curl_setopt($ch, CURLOPT_UPLOAD, true);
+curl_setopt($ch, CURLOPT_INFILESIZE, strlen($body));
+curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+curl_setopt($ch, CURLOPT_READFUNCTION, function ($ch, $fd, int $length) use ($body, &$offset) {
+    $chunk = substr($body, $offset, $length);
+    $offset += strlen($chunk);
+    return $chunk;
+});
+curl_setopt($ch, CURLOPT_SEEKFUNCTION, function ($ch, int $position, int $origin) use (&$offset, &$seekCalls) {
+    if ($origin !== SEEK_SET) {
+        return CURL_SEEKFUNC_CANTSEEK;
+    }
+    $seekCalls++;
+    $offset = $position;
+    return CURL_SEEKFUNC_OK;
+});
+
+// The copied handle must inherit the seek callback; exercise it on the copy
+// after freeing the original.
+$ch2 = curl_copy_handle($ch);
+unset($ch);
+
+$response = curl_exec($ch2);
+var_dump($seekCalls > 0);
+var_dump(str_contains($response, $body));
+?>
+--EXPECT--
+bool(true)
+bool(true)
diff --git a/ext/curl/tests/curl_seekfunction.phpt b/ext/curl/tests/curl_seekfunction.phpt
new file mode 100644
index 00000000000..d2daa1e7d8d
--- /dev/null
+++ b/ext/curl/tests/curl_seekfunction.phpt
@@ -0,0 +1,51 @@
+--TEST--
+CURLOPT_SEEKFUNCTION is called to rewind a streamed upload across a redirect
+--EXTENSIONS--
+curl
+--FILE--
+<?php
+include 'server.inc';
+$host = curl_cli_server_start();
+
+$body = 'Hello cURL seek!';
+$offset = 0;
+$seekCalls = 0;
+$argsChecked = false;
+
+$ch = curl_init("{$host}/get.inc?test=redirect");
+curl_setopt($ch, CURLOPT_UPLOAD, true);
+curl_setopt($ch, CURLOPT_INFILESIZE, strlen($body));
+curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+curl_setopt($ch, CURLOPT_READFUNCTION, function ($ch, $fd, int $length) use ($body, &$offset) {
+    $chunk = substr($body, $offset, $length);
+    $offset += strlen($chunk);
+    return $chunk;
+});
+curl_setopt($ch, CURLOPT_SEEKFUNCTION, function ($ch, int $position, int $origin) use (&$offset, &$seekCalls, &$argsChecked) {
+    if (!$argsChecked) {
+        $argsChecked = true;
+        var_dump($ch instanceof CurlHandle);
+        var_dump($position === 0);
+        var_dump($origin === SEEK_SET);
+    }
+    if ($origin !== SEEK_SET) {
+        return CURL_SEEKFUNC_CANTSEEK;
+    }
+    $seekCalls++;
+    $offset = $position;
+    return CURL_SEEKFUNC_OK;
+});
+
+$response = curl_exec($ch);
+// The seek callback must have been invoked to rewind the body for the resend,
+// and the resent body must have reached the redirect target intact.
+var_dump($seekCalls > 0);
+var_dump(str_contains($response, $body));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/curl/tests/curl_seekfunction_error.phpt b/ext/curl/tests/curl_seekfunction_error.phpt
new file mode 100644
index 00000000000..134e8115dc5
--- /dev/null
+++ b/ext/curl/tests/curl_seekfunction_error.phpt
@@ -0,0 +1,77 @@
+--TEST--
+CURLOPT_SEEKFUNCTION callback error handling and option validation
+--EXTENSIONS--
+curl
+--FILE--
+<?php
+include 'server.inc';
+$host = curl_cli_server_start();
+
+// Drive a 307-redirect upload so libcurl invokes the seek callback to rewind
+// the body; $seek is the callback under test.
+function run_upload(string $host, callable $seek): void
+{
+    $offset = 0;
+    $body = 'Hello cURL seek!';
+    $ch = curl_init("{$host}/get.inc?test=redirect");
+    curl_setopt($ch, CURLOPT_UPLOAD, true);
+    curl_setopt($ch, CURLOPT_INFILESIZE, strlen($body));
+    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+    curl_setopt($ch, CURLOPT_READFUNCTION, function ($ch, $fd, int $length) use ($body, &$offset) {
+        $chunk = substr($body, $offset, $length);
+        $offset += strlen($chunk);
+        return $chunk;
+    });
+    curl_setopt($ch, CURLOPT_SEEKFUNCTION, $seek);
+    curl_exec($ch);
+}
+
+echo "Returning a non-int:\n";
+try {
+    run_upload($host, fn($ch, $offset, $origin) => 'not an int');
+} catch (\TypeError $e) {
+    echo $e->getMessage(), "\n";
+}
+
+echo "\nReturning an out-of-range int:\n";
+try {
+    run_upload($host, fn($ch, $offset, $origin) => 42);
+} catch (\ValueError $e) {
+    echo $e->getMessage(), "\n";
+}
+
+echo "\nThrowing from the callback:\n";
+try {
+    run_upload($host, function ($ch, $offset, $origin) {
+        throw new \RuntimeException('boom from seek');
+    });
+} catch (\RuntimeException $e) {
+    echo $e->getMessage(), "\n";
+}
+
+echo "\nSetting the callback to null:\n";
+var_dump(curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, null));
+
+echo "\nSetting a non-callable scalar:\n";
+try {
+    curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, 42);
+} catch (\TypeError $e) {
+    echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+Returning a non-int:
+The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK
+
+Returning an out-of-range int:
+The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK
+
+Throwing from the callback:
+boom from seek
+
+Setting the callback to null:
+bool(true)
+
+Setting a non-callable scalar:
+curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, no array or string given
diff --git a/ext/curl/tests/curl_setopt_callables.phpt b/ext/curl/tests/curl_setopt_callables.phpt
index aaa83102afa..9de8d670571 100644
--- a/ext/curl/tests/curl_setopt_callables.phpt
+++ b/ext/curl/tests/curl_setopt_callables.phpt
@@ -27,6 +27,7 @@ function testOption(CurlHandle $handle, int $option) {
 testOption($ch, CURLOPT_WRITEFUNCTION);
 testOption($ch, CURLOPT_HEADERFUNCTION);
 testOption($ch, CURLOPT_READFUNCTION);
+testOption($ch, CURLOPT_SEEKFUNCTION);

 ?>
 --EXPECT--
@@ -42,3 +43,5 @@ function testOption(CurlHandle $handle, int $option) {
 TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_HEADERFUNCTION, function "undefined" not found or invalid function name
 TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name
 TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name
+TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name
+TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name
diff --git a/ext/curl/tests/responder/get.inc b/ext/curl/tests/responder/get.inc
index c139c8c7d43..2ef1e4a89dd 100644
--- a/ext/curl/tests/responder/get.inc
+++ b/ext/curl/tests/responder/get.inc
@@ -46,6 +46,11 @@
     case 'method':
       echo $_SERVER['REQUEST_METHOD'];
       break;
+    case 'redirect':
+      // A 307 preserves the method and body, so libcurl must rewind the upload
+      // (via CURLOPT_SEEKFUNCTION) before resending it to the new location.
+      header('Location: /get.inc?test=input', true, 307);
+      break;
     default:
       echo "Hello World!\n";
       echo "Hello World!";