Skip to content

Commit 6c293e4

Browse files
ext/curl: add CURLOPT_SEEKFUNCTION
Expose libcurl's CURLOPT_SEEKFUNCTION as a userland callable so a request body streamed via CURLOPT_READFUNCTION can be rewound and resent when libcurl needs to replay it: on a redirect, on multi-pass authentication (NTLM/Negotiate), or when a reused connection drops after bytes have been sent. Without a seek callback those transfers fail with CURLE_SEND_FAIL_REWIND, the gap behind bug #47204 and bug #80518. The callback receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END) and returns one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK. The implementation mirrors the existing callback options: a seek FCC on php_curl_handlers, a curl_seek trampoline that validates the return value the way curl_prereqfunction does, registration through the HANDLE_CURL_OPTION_CALLABLE macro, and handling in init, setopt, copy (curl_copy_handle), reset (curl_reset), free and the cycle collector (curl_get_gc). The option and constants have existed since libcurl 7.18.0, below the 7.61.0 configure floor, so no version guard is needed. Tests cover the happy-path rewind across a redirect, copy-handle inheritance, callable validation, and the callback error paths.
1 parent 95b5b48 commit 6c293e4

11 files changed

Lines changed: 286 additions & 1 deletion

NEWS

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ PHP NEWS
2525
- BCMath:
2626
. Added NUL-byte validation to BCMath functions. (jorgsowa)
2727

28+
- Curl:
29+
. Added CURLOPT_SEEKFUNCTION and the CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL
30+
and CURL_SEEKFUNC_CANTSEEK constants, letting libcurl rewind a streamed
31+
request body to resend it on a redirect, multi-pass authentication or a
32+
retried reused connection. (GrahamCampbell)
33+
2834
- Date:
2935
. Update timelib to 2022.16. (Derick)
3036

UPGRADING

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ PHP 8.6 UPGRADE NOTES
179179
. It is now possible to define the `__debugInfo()` magic method on enums.
180180
RFC: https://wiki.php.net/rfc/debugable-enums
181181

182+
- Curl:
183+
. Added CURLOPT_SEEKFUNCTION to register a callback that repositions a
184+
streamed request body so libcurl can rewind and resend it on a redirect,
185+
multi-pass authentication, or a retried reused connection instead of
186+
failing with CURLE_SEND_FAIL_REWIND. The callback receives the CurlHandle,
187+
offset and origin, and must return one of CURL_SEEKFUNC_OK,
188+
CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK.
189+
182190
- Fileinfo:
183191
. finfo_file() now works with remote streams.
184192

@@ -367,6 +375,12 @@ PHP 8.6 UPGRADE NOTES
367375
10. New Global Constants
368376
========================================
369377

378+
- Curl:
379+
. CURLOPT_SEEKFUNCTION.
380+
. CURL_SEEKFUNC_OK.
381+
. CURL_SEEKFUNC_FAIL.
382+
. CURL_SEEKFUNC_CANTSEEK.
383+
370384
- Sockets:
371385
. TCP_USER_TIMEOUT (Linux only).
372386
. AF_UNSPEC.

ext/curl/curl.stub.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,11 @@
343343
* @cvalue CURLOPT_RETURNTRANSFER
344344
*/
345345
const CURLOPT_RETURNTRANSFER = UNKNOWN;
346+
/**
347+
* @var int
348+
* @cvalue CURLOPT_SEEKFUNCTION
349+
*/
350+
const CURLOPT_SEEKFUNCTION = UNKNOWN;
346351
/**
347352
* @var int
348353
* @cvalue CURLOPT_SHARE
@@ -1788,6 +1793,21 @@
17881793
* @cvalue CURL_READFUNC_PAUSE
17891794
*/
17901795
const CURL_READFUNC_PAUSE = UNKNOWN;
1796+
/**
1797+
* @var int
1798+
* @cvalue CURL_SEEKFUNC_OK
1799+
*/
1800+
const CURL_SEEKFUNC_OK = UNKNOWN;
1801+
/**
1802+
* @var int
1803+
* @cvalue CURL_SEEKFUNC_FAIL
1804+
*/
1805+
const CURL_SEEKFUNC_FAIL = UNKNOWN;
1806+
/**
1807+
* @var int
1808+
* @cvalue CURL_SEEKFUNC_CANTSEEK
1809+
*/
1810+
const CURL_SEEKFUNC_CANTSEEK = UNKNOWN;
17911811
/**
17921812
* @var int
17931813
* @cvalue CURL_WRITEFUNC_PAUSE

ext/curl/curl_arginfo.h

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ext/curl/curl_private.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ typedef struct {
7474
php_curl_write *write_header;
7575
php_curl_read *read;
7676
zval std_err;
77+
zend_fcall_info_cache seek;
7778
zend_fcall_info_cache progress;
7879
zend_fcall_info_cache xferinfo;
7980
zend_fcall_info_cache fnmatch;

ext/curl/interface.c

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,10 @@ static HashTable *curl_get_gc(zend_object *object, zval **table, int *n)
456456
zend_get_gc_buffer_add_zval(gc_buffer, &curl->handlers.write_header->stream);
457457
}
458458

459+
if (ZEND_FCC_INITIALIZED(curl->handlers.seek)) {
460+
zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.seek);
461+
}
462+
459463
if (ZEND_FCC_INITIALIZED(curl->handlers.progress)) {
460464
zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.progress);
461465
}
@@ -831,6 +835,52 @@ static size_t curl_read(char *data, size_t size, size_t nmemb, void *ctx)
831835
}
832836
/* }}} */
833837

838+
/* {{{ curl_seek */
839+
static int curl_seek(void *clientp, curl_off_t offset, int origin)
840+
{
841+
php_curl *ch = (php_curl *)clientp;
842+
int rval = CURL_SEEKFUNC_CANTSEEK; /* safe default if unset or the callback misbehaves */
843+
844+
#if PHP_CURL_DEBUG
845+
fprintf(stderr, "curl_seek() called\n");
846+
fprintf(stderr, "clientp = %x, offset = %ld, origin = %d\n", clientp, offset, origin);
847+
#endif
848+
if (!ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
849+
return rval;
850+
}
851+
852+
zval args[3];
853+
zval retval;
854+
855+
GC_ADDREF(&ch->std);
856+
ZVAL_OBJ(&args[0], &ch->std);
857+
ZVAL_LONG(&args[1], offset);
858+
ZVAL_LONG(&args[2], origin);
859+
860+
ch->in_callback = true;
861+
zend_call_known_fcc(&ch->handlers.seek, &retval, /* param_count */ 3, args, /* named_params */ NULL);
862+
ch->in_callback = false;
863+
864+
if (!Z_ISUNDEF(retval)) {
865+
_php_curl_verify_handlers(ch, /* reporterror */ true);
866+
if (Z_TYPE(retval) == IS_LONG) {
867+
zend_long retval_long = Z_LVAL(retval);
868+
if (retval_long == CURL_SEEKFUNC_OK || retval_long == CURL_SEEKFUNC_FAIL || retval_long == CURL_SEEKFUNC_CANTSEEK) {
869+
rval = retval_long;
870+
} else {
871+
zend_value_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK");
872+
}
873+
} else {
874+
zend_type_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK");
875+
}
876+
zval_ptr_dtor(&retval);
877+
}
878+
879+
zval_ptr_dtor(&args[0]);
880+
return rval;
881+
}
882+
/* }}} */
883+
834884
/* {{{ curl_write_header */
835885
static size_t curl_write_header(char *data, size_t size, size_t nmemb, void *ctx)
836886
{
@@ -1038,6 +1088,7 @@ void init_curl_handle(php_curl *ch)
10381088
ch->handlers.write = ecalloc(1, sizeof(php_curl_write));
10391089
ch->handlers.write_header = ecalloc(1, sizeof(php_curl_write));
10401090
ch->handlers.read = ecalloc(1, sizeof(php_curl_read));
1091+
ch->handlers.seek = empty_fcall_info_cache;
10411092
ch->handlers.progress = empty_fcall_info_cache;
10421093
ch->handlers.xferinfo = empty_fcall_info_cache;
10431094
ch->handlers.fnmatch = empty_fcall_info_cache;
@@ -1208,6 +1259,7 @@ void _php_setup_easy_copy_handlers(php_curl *ch, php_curl *source)
12081259
curl_easy_setopt(ch->cp, CURLOPT_WRITEHEADER, (void *) ch);
12091260
curl_easy_setopt(ch->cp, CURLOPT_DEBUGDATA, (void *) ch);
12101261

1262+
php_curl_copy_fcc_with_option(ch, CURLOPT_SEEKDATA, &ch->handlers.seek, &source->handlers.seek);
12111263
php_curl_copy_fcc_with_option(ch, CURLOPT_PROGRESSDATA, &ch->handlers.progress, &source->handlers.progress);
12121264
php_curl_copy_fcc_with_option(ch, CURLOPT_XFERINFODATA, &ch->handlers.xferinfo, &source->handlers.xferinfo);
12131265
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
15771629
HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_HEADER, write_header, PHP_CURL_IGNORE);
15781630
HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_READ, read, PHP_CURL_DIRECT);
15791631

1632+
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_SEEK, handlers.seek, curl_seek);
15801633
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_PROGRESS, handlers.progress, curl_progress);
15811634
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_XFERINFO, handlers.xferinfo, curl_xferinfo);
15821635
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_FNMATCH_, handlers.fnmatch, curl_fnmatch);
@@ -2781,6 +2834,9 @@ static void curl_free_obj(zend_object *object)
27812834
efree(ch->handlers.write_header);
27822835
efree(ch->handlers.read);
27832836

2837+
if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
2838+
zend_fcc_dtor(&ch->handlers.seek);
2839+
}
27842840
if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) {
27852841
zend_fcc_dtor(&ch->handlers.progress);
27862842
}
@@ -2865,6 +2921,10 @@ static void _php_curl_reset_handlers(php_curl *ch)
28652921
ZVAL_UNDEF(&ch->handlers.std_err);
28662922
}
28672923

2924+
if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
2925+
zend_fcc_dtor(&ch->handlers.seek);
2926+
}
2927+
28682928
if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) {
28692929
zend_fcc_dtor(&ch->handlers.progress);
28702930
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
--TEST--
2+
Test curl_copy_handle() with CURLOPT_SEEKFUNCTION
3+
--EXTENSIONS--
4+
curl
5+
--FILE--
6+
<?php
7+
include 'server.inc';
8+
$host = curl_cli_server_start();
9+
10+
$body = 'Hello cURL seek!';
11+
$offset = 0;
12+
$seekCalls = 0;
13+
14+
$ch = curl_init("{$host}/get.inc?test=redirect");
15+
curl_setopt($ch, CURLOPT_UPLOAD, true);
16+
curl_setopt($ch, CURLOPT_INFILESIZE, strlen($body));
17+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
18+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
19+
curl_setopt($ch, CURLOPT_READFUNCTION, function ($ch, $fd, int $length) use ($body, &$offset) {
20+
$chunk = substr($body, $offset, $length);
21+
$offset += strlen($chunk);
22+
return $chunk;
23+
});
24+
curl_setopt($ch, CURLOPT_SEEKFUNCTION, function ($ch, int $position, int $origin) use (&$offset, &$seekCalls) {
25+
if ($origin !== SEEK_SET) {
26+
return CURL_SEEKFUNC_CANTSEEK;
27+
}
28+
$seekCalls++;
29+
$offset = $position;
30+
return CURL_SEEKFUNC_OK;
31+
});
32+
33+
// The copied handle must inherit the seek callback; exercise it on the copy
34+
// after freeing the original.
35+
$ch2 = curl_copy_handle($ch);
36+
unset($ch);
37+
38+
$response = curl_exec($ch2);
39+
var_dump($seekCalls > 0);
40+
var_dump(str_contains($response, $body));
41+
?>
42+
--EXPECT--
43+
bool(true)
44+
bool(true)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
--TEST--
2+
CURLOPT_SEEKFUNCTION is called to rewind a streamed upload across a redirect
3+
--EXTENSIONS--
4+
curl
5+
--FILE--
6+
<?php
7+
include 'server.inc';
8+
$host = curl_cli_server_start();
9+
10+
$body = 'Hello cURL seek!';
11+
$offset = 0;
12+
$seekCalls = 0;
13+
$argsChecked = false;
14+
15+
$ch = curl_init("{$host}/get.inc?test=redirect");
16+
curl_setopt($ch, CURLOPT_UPLOAD, true);
17+
curl_setopt($ch, CURLOPT_INFILESIZE, strlen($body));
18+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
19+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
20+
curl_setopt($ch, CURLOPT_READFUNCTION, function ($ch, $fd, int $length) use ($body, &$offset) {
21+
$chunk = substr($body, $offset, $length);
22+
$offset += strlen($chunk);
23+
return $chunk;
24+
});
25+
curl_setopt($ch, CURLOPT_SEEKFUNCTION, function ($ch, int $position, int $origin) use (&$offset, &$seekCalls, &$argsChecked) {
26+
if (!$argsChecked) {
27+
$argsChecked = true;
28+
var_dump($ch instanceof CurlHandle);
29+
var_dump($position === 0);
30+
var_dump($origin === SEEK_SET);
31+
}
32+
if ($origin !== SEEK_SET) {
33+
return CURL_SEEKFUNC_CANTSEEK;
34+
}
35+
$seekCalls++;
36+
$offset = $position;
37+
return CURL_SEEKFUNC_OK;
38+
});
39+
40+
$response = curl_exec($ch);
41+
// The seek callback must have been invoked to rewind the body for the resend,
42+
// and the resent body must have reached the redirect target intact.
43+
var_dump($seekCalls > 0);
44+
var_dump(str_contains($response, $body));
45+
?>
46+
--EXPECT--
47+
bool(true)
48+
bool(true)
49+
bool(true)
50+
bool(true)
51+
bool(true)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
--TEST--
2+
CURLOPT_SEEKFUNCTION callback error handling and option validation
3+
--EXTENSIONS--
4+
curl
5+
--FILE--
6+
<?php
7+
include 'server.inc';
8+
$host = curl_cli_server_start();
9+
10+
// Drive a 307-redirect upload so libcurl invokes the seek callback to rewind
11+
// the body; $seek is the callback under test.
12+
function run_upload(string $host, callable $seek): void
13+
{
14+
$offset = 0;
15+
$body = 'Hello cURL seek!';
16+
$ch = curl_init("{$host}/get.inc?test=redirect");
17+
curl_setopt($ch, CURLOPT_UPLOAD, true);
18+
curl_setopt($ch, CURLOPT_INFILESIZE, strlen($body));
19+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
20+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
21+
curl_setopt($ch, CURLOPT_READFUNCTION, function ($ch, $fd, int $length) use ($body, &$offset) {
22+
$chunk = substr($body, $offset, $length);
23+
$offset += strlen($chunk);
24+
return $chunk;
25+
});
26+
curl_setopt($ch, CURLOPT_SEEKFUNCTION, $seek);
27+
curl_exec($ch);
28+
}
29+
30+
echo "Returning a non-int:\n";
31+
try {
32+
run_upload($host, fn($ch, $offset, $origin) => 'not an int');
33+
} catch (\TypeError $e) {
34+
echo $e->getMessage(), "\n";
35+
}
36+
37+
echo "\nReturning an out-of-range int:\n";
38+
try {
39+
run_upload($host, fn($ch, $offset, $origin) => 42);
40+
} catch (\ValueError $e) {
41+
echo $e->getMessage(), "\n";
42+
}
43+
44+
echo "\nThrowing from the callback:\n";
45+
try {
46+
run_upload($host, function ($ch, $offset, $origin) {
47+
throw new \RuntimeException('boom from seek');
48+
});
49+
} catch (\RuntimeException $e) {
50+
echo $e->getMessage(), "\n";
51+
}
52+
53+
echo "\nSetting the callback to null:\n";
54+
var_dump(curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, null));
55+
56+
echo "\nSetting a non-callable scalar:\n";
57+
try {
58+
curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, 42);
59+
} catch (\TypeError $e) {
60+
echo $e->getMessage(), "\n";
61+
}
62+
?>
63+
--EXPECT--
64+
Returning a non-int:
65+
The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK
66+
67+
Returning an out-of-range int:
68+
The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK
69+
70+
Throwing from the callback:
71+
boom from seek
72+
73+
Setting the callback to null:
74+
bool(true)
75+
76+
Setting a non-callable scalar:
77+
curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, no array or string given

0 commit comments

Comments
 (0)