diff --git a/src/Curl/CaseInsensitiveArray.php b/src/Curl/CaseInsensitiveArray.php index 96afb38..93ca6f9 100644 --- a/src/Curl/CaseInsensitiveArray.php +++ b/src/Curl/CaseInsensitiveArray.php @@ -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. diff --git a/src/Curl/CookieArray.php b/src/Curl/CookieArray.php new file mode 100644 index 0000000..f391bae --- /dev/null +++ b/src/Curl/CookieArray.php @@ -0,0 +1,185 @@ +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); + } +} diff --git a/src/Curl/Curl.php b/src/Curl/Curl.php index 8c201fd..b178059 100644 --- a/src/Curl/Curl.php +++ b/src/Curl/Curl.php @@ -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) { diff --git a/tests/PHPCurlClass/PHPCurlClassTest.php b/tests/PHPCurlClass/PHPCurlClassTest.php index d22414a..35d00b7 100644 --- a/tests/PHPCurlClass/PHPCurlClassTest.php +++ b/tests/PHPCurlClass/PHPCurlClassTest.php @@ -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) { diff --git a/tests/server.php b/tests/server.php index ceb7da4..e30e015 100644 --- a/tests/server.php +++ b/tests/server.php @@ -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'));