Implement CookieArray for handling decoded cookie name lookup (#1078)

This commit is contained in:
Zach Borboa
2026-04-19 22:20:08 -04:00
committed by GitHub
parent f50fbcfff5
commit 1d50c96014
5 changed files with 240 additions and 6 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ class CaseInsensitiveArray implements \ArrayAccess, \Countable, \Iterator
*
* Set data at a specified offset. Converts the offset to lowercase, and
* stores the case-sensitive offset and the data at the lowercase indexes in
* $this->keys and @this->data.
* $this->keys and $this->data.
*
* @param string $offset The offset to store the data at (case-insensitive).
* @param mixed $value The data to store at the specified offset.
+185
View File
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Curl;
/**
* Cookie Array
*
* An array-like container for response cookies that transparently decodes
* percent-encoded keys on access. Keys are normalized with rawurldecode()
* on both storage and lookup, so an entry stored as "a;b" can be retrieved
* via either $cookies['a;b'] or $cookies['a%3Bb'].
*
* @psalm-suppress MissingTemplateParam
*/
class CookieArray implements \ArrayAccess, \Countable, \Iterator
{
/**
* @var mixed[] Data storage with url-decoded keys.
*
* @see offsetSet()
* @see offsetExists()
* @see offsetUnset()
* @see offsetGet()
* @see count()
* @see current()
* @see next()
* @see key()
* @see valid()
* @see rewind()
*/
private $data = [];
/**
* Offset Set
*
* Set data at a specified offset. Decodes the offset using rawurldecode
* and stores the data at the decoded index.
*
* @param string $offset The offset to store the data at.
* @param mixed $value The data to store at the specified offset.
* @return void
* @see https://www.php.net/manual/en/arrayaccess.offsetset.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if ($offset === null) {
$this->data[] = $value;
} else {
$this->data[rawurldecode((string) $offset)] = $value;
}
}
/**
* Offset Exists
*
* Checks if the offset exists in data storage. The index is looked up with
* the url-decoded version of the provided offset.
*
* @param string $offset The offset to check.
* @return bool If the offset exists.
* @see https://www.php.net/manual/en/arrayaccess.offsetexists.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return array_key_exists(rawurldecode((string) $offset), $this->data);
}
/**
* Offset Unset
*
* Unsets the specified offset. Decodes the provided offset using
* rawurldecode and unsets the stored data.
*
* @param string $offset The offset to unset.
* @return void
* @see https://www.php.net/manual/en/arrayaccess.offsetunset.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->data[rawurldecode((string) $offset)]);
}
/**
* Offset Get
*
* Return the stored data at the provided offset. The offset is decoded
* using rawurldecode and the lookup is done on the data store directly.
*
* @param string $offset The offset to look up.
* @return mixed The data stored at the offset.
* @see https://www.php.net/manual/en/arrayaccess.offsetget.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return $this->data[rawurldecode((string) $offset)] ?? null;
}
/**
* Count
*
* @return int The number of elements stored in the array.
* @see https://www.php.net/manual/en/countable.count.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function count()
{
return count($this->data);
}
/**
* Current
*
* @return mixed Data at the current position.
* @see https://www.php.net/manual/en/iterator.current.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function current()
{
return current($this->data);
}
/**
* Next
*
* @return void
* @see https://www.php.net/manual/en/iterator.next.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function next()
{
next($this->data);
}
/**
* Key
*
* @return mixed Decoded key at current position.
* @see https://www.php.net/manual/en/iterator.key.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function key()
{
return key($this->data);
}
/**
* Valid
*
* @return bool If the current position is valid.
* @see https://www.php.net/manual/en/iterator.valid.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function valid()
{
return (key($this->data) !== null);
}
/**
* Rewind
*
* @return void
* @see https://www.php.net/manual/en/iterator.rewind.php
*/
#[\Override]
#[\ReturnTypeWillChange]
public function rewind()
{
reset($this->data);
}
}
+6 -5
View File
@@ -29,7 +29,7 @@ class Curl extends BaseCurl
public $responseHeaders = null;
public $rawResponseHeaders = '';
public $responseCookies = [];
public $responseCookies = null;
public $response = null;
public $rawResponse = null;
@@ -489,7 +489,7 @@ class Curl extends BaseCurl
}
if ($ch === null) {
$this->responseCookies = [];
$this->responseCookies = new CookieArray();
$this->call($this->beforeSendCallback);
$this->rawResponse = curl_exec($this->curl);
$this->curlErrorCode = curl_errno($this->curl);
@@ -511,7 +511,7 @@ class Curl extends BaseCurl
$this->rawResponseHeaders = $this->headerCallbackData->rawResponseHeaders;
$this->responseCookies = $this->headerCallbackData->responseCookies;
$this->headerCallbackData->rawResponseHeaders = '';
$this->headerCallbackData->responseCookies = [];
$this->headerCallbackData->responseCookies = new CookieArray();
$this->headerCallbackData->stopRequestDecider = null;
$this->headerCallbackData->stopRequest = false;
@@ -2150,7 +2150,7 @@ class Curl extends BaseCurl
// Create a placeholder to temporarily store the header callback data.
$header_callback_data = new \stdClass();
$header_callback_data->rawResponseHeaders = '';
$header_callback_data->responseCookies = [];
$header_callback_data->responseCookies = new CookieArray();
$header_callback_data->stopRequestDecider = null;
$header_callback_data->stopRequest = false;
$this->headerCallbackData = $header_callback_data;
@@ -2159,6 +2159,7 @@ class Curl extends BaseCurl
$this->setOptInternal(CURLOPT_RETURNTRANSFER, true);
$this->headers = new CaseInsensitiveArray();
$this->responseCookies = new CookieArray();
if ($base_url !== null) {
$this->setUrl($base_url);
@@ -2230,7 +2231,7 @@ function createHeaderCallback($header_callback_data)
{
return function ($ch, $header) use ($header_callback_data) {
if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/mi', $header, $cookie) === 1) {
$header_callback_data->responseCookies[$cookie[1]] = trim($cookie[2], " \n\r\t\0\x0B");
$header_callback_data->responseCookies[$cookie[1]] = rawurldecode(trim($cookie[2], " \n\r\t\0\x0B"));
}
if ($header_callback_data->stopRequestDecider !== null) {
+43
View File
@@ -1238,6 +1238,49 @@ class PHPCurlClassTest extends \PHPUnit\Framework\TestCase
$this->assertEquals('mouthwatering', $test->curl->responseCookies['cookie2']);
}
public function testEncodedCookieResponse()
{
$test = new Test();
$test->server('encoded_cookie', 'GET');
// Ensure decoded key lookup returns decoded values.
$this->assertEquals('foo;bar', $test->curl->getCookie('dingus'));
$this->assertEquals('foo;bar', $test->curl->getCookie('a;b'));
// Ensure encoded key lookup still works for backwards compatibility.
$this->assertEquals('foo;bar', $test->curl->getCookie('a%3Bb'));
// Ensure percent-encoded spaces in values are decoded.
$this->assertEquals('hello world', $test->curl->getCookie('spaced'));
// Ensure missing cookies return null.
$this->assertNull($test->curl->getCookie('nonexistent'));
// Ensure direct property access works with both decoded and encoded keys.
$this->assertEquals('foo;bar', $test->curl->responseCookies['a;b']);
$this->assertEquals('foo;bar', $test->curl->responseCookies['a%3Bb']);
// Ensure isset() works with both decoded and encoded keys.
$this->assertTrue(isset($test->curl->responseCookies['a;b']));
$this->assertTrue(isset($test->curl->responseCookies['a%3Bb']));
$this->assertFalse(isset($test->curl->responseCookies['nonexistent']));
// Ensure iteration yields decoded keys only.
$iterated_keys = [];
foreach ($test->curl->responseCookies as $key => $value) {
$iterated_keys[] = $key;
}
$this->assertEqualsCanonicalizing(['dingus', 'a;b', 'spaced'], $iterated_keys);
// Ensure unset() via the encoded key removes the decoded entry.
unset($test->curl->responseCookies['a%3Bb']);
$this->assertFalse(isset($test->curl->responseCookies['a;b']));
$this->assertFalse(isset($test->curl->responseCookies['a%3Bb']));
// Ensure responseCookies stores decoded keys only without duplicates.
$this->assertCount(2, $test->curl->getResponseCookies());
}
public function testDefaultTimeout()
{
if ($this->skip_slow_tests) {
+5
View File
@@ -176,6 +176,11 @@ if ($test === 'http_basic_auth') {
setcookie('cookie1', 'scrumptious');
setcookie('cookie2', 'mouthwatering');
exit;
} elseif ($test === 'encoded_cookie') {
header('Set-Cookie: dingus=foo%3Bbar; path=/');
header('Set-Cookie: a%3Bb=foo%3Bbar; path=/', false);
header('Set-Cookie: spaced=hello%20world; path=/', false);
exit;
} elseif ($test === 'response_header') {
header('Content-Type: application/json');
header('ETag: ' . md5('worldpeace'));