// SPDX-FileCopyrightText: 2004-2023 Ryan Parman, Sam Sneddon, Ryan McCue
// SPDX-License-Identifier: BSD-3-Clause
namespace SimplePie\HTTP;
* @template Psr7Compatible of bool
public $http_version = 0.0;
* @var Psr7Compatible whether headers are compatible with PSR-7 format.
* Key/value pairs of the headers
* @var (Psr7Compatible is true ? array<string, non-empty-array<string>> : array<string, string>)
private const STATE_HTTP_VERSION = 'http_version';
private const STATE_STATUS = 'status';
private const STATE_REASON = 'reason';
private const STATE_NEW_LINE = 'new_line';
private const STATE_BODY = 'body';
private const STATE_NAME = 'name';
private const STATE_VALUE = 'value';
private const STATE_VALUE_CHAR = 'value_char';
private const STATE_QUOTE = 'quote';
private const STATE_QUOTE_ESCAPED = 'quote_escaped';
private const STATE_QUOTE_CHAR = 'quote_char';
private const STATE_CHUNKED = 'chunked';
private const STATE_EMIT = 'emit';
private const STATE_ERROR = false;
* Current state of the state machine
protected $state = self::STATE_HTTP_VERSION;
* Input data length (to avoid calling strlen() everytime this is needed)
protected $data_length = 0;
* Current position of the pointer
* Name of the header currently being parsed
* Value of the header currently being parsed
* Create an instance of the class with the input data
* @param string $data Input data
* @param Psr7Compatible $psr7Compatible Whether the data types are in format compatible with PSR-7.
public function __construct(string $data, bool $psr7Compatible = false)
$this->data_length = strlen($this->data);
$this->psr7Compatible = $psr7Compatible;
* @return bool true on success, false on failure
while ($this->state && $this->state !== self::STATE_EMIT && $this->has_data()) {
if ($this->state === self::STATE_EMIT || $this->state === self::STATE_BODY) {
// Reset the parser state.
$this->http_version = 0.0;
* Check whether there is data beyond the pointer
* @return bool true if there is further data, false if not
protected function has_data()
return (bool) ($this->position < $this->data_length);
* See if the next character is LWS
* @return bool true if the next character is LWS, false if not
protected function is_linear_whitespace()
return (bool) ($this->data[$this->position] === "\x09"
|| $this->data[$this->position] === "\x20"
|| ($this->data[$this->position] === "\x0A"
&& isset($this->data[$this->position + 1])
&& ($this->data[$this->position + 1] === "\x09" || $this->data[$this->position + 1] === "\x20")));
protected function http_version()
if (strpos($this->data, "\x0A") !== false && strtoupper(substr($this->data, 0, 5)) === 'HTTP/') {
$len = strspn($this->data, '0123456789.', 5);
$http_version = substr($this->data, 5, $len);
$this->position += 5 + $len;
if (substr_count($http_version, '.') <= 1) {
$this->http_version = (float) $http_version;
$this->position += strspn($this->data, "\x09\x20", $this->position);
$this->state = self::STATE_STATUS;
$this->state = self::STATE_ERROR;
$this->state = self::STATE_ERROR;
protected function status()
if ($len = strspn($this->data, '0123456789', $this->position)) {
$this->status_code = (int) substr($this->data, $this->position, $len);
$this->state = self::STATE_REASON;
$this->state = self::STATE_ERROR;
* Parse the reason phrase
protected function reason()
$len = strcspn($this->data, "\x0A", $this->position);
$this->reason = trim(substr($this->data, $this->position, $len), "\x09\x0D\x20");
$this->position += $len + 1;
$this->state = self::STATE_NEW_LINE;
private function add_header(string $name, string $value): void
if ($this->psr7Compatible) {
// For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
/** @var array<string, non-empty-array<string>> */
$headers = &$this->headers;
$headers[$name][] = $value;
// For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
/** @var array<string, string>) */
$headers = &$this->headers;
$headers[$name] .= ', ' . $value;
private function replace_header(string $name, string $value): void
if ($this->psr7Compatible) {
// For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
/** @var array<string, non-empty-array<string>> */
$headers = &$this->headers;
$headers[$name] = [$value];
// For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
/** @var array<string, string>) */
$headers = &$this->headers;
$headers[$name] = $value;
* Deal with a new line, shifting data around as needed
protected function new_line()
$this->value = trim($this->value, "\x0D\x20");
if ($this->name !== '' && $this->value !== '') {
$this->name = strtolower($this->name);
// We should only use the last Content-Type header. c.f. issue #1
if (isset($this->headers[$this->name]) && $this->name !== 'content-type') {
$this->add_header($this->name, $this->value);
$this->replace_header($this->name, $this->value);
if (substr($this->data[$this->position], 0, 2) === "\x0D\x0A") {
$this->state = self::STATE_BODY;
} elseif ($this->data[$this->position] === "\x0A") {
$this->state = self::STATE_BODY;
$this->state = self::STATE_NAME;
protected function name()
$len = strcspn($this->data, "\x0A:", $this->position);
if (isset($this->data[$this->position + $len])) {
if ($this->data[$this->position + $len] === "\x0A") {
$this->state = self::STATE_NEW_LINE;
$this->name = substr($this->data, $this->position, $len);
$this->position += $len + 1;
$this->state = self::STATE_VALUE;
$this->state = self::STATE_ERROR;
* Parse LWS, replacing consecutive LWS characters with a single space
protected function linear_whitespace()
if (substr($this->data, $this->position, 2) === "\x0D\x0A") {
} elseif ($this->data[$this->position] === "\x0A") {
$this->position += strspn($this->data, "\x09\x20", $this->position);
} while ($this->has_data() && $this->is_linear_whitespace());
* See what state to move to while within non-quoted header values
protected function value()
if ($this->is_linear_whitespace()) {
$this->linear_whitespace();
switch ($this->data[$this->position]) {
// Workaround for ETags: we have to include the quotes as
if (strtolower($this->name) === 'etag') {
$this->state = self::STATE_VALUE_CHAR;
$this->state = self::STATE_QUOTE;
$this->state = self::STATE_NEW_LINE;
$this->state = self::STATE_VALUE_CHAR;
* Parse a header value while outside quotes
protected function value_char()
$len = strcspn($this->data, "\x09\x20\x0A\"", $this->position);
$this->value .= substr($this->data, $this->position, $len);
$this->state = self::STATE_VALUE;
* See what state to move to while within quoted header values
protected function quote()
if ($this->is_linear_whitespace()) {
$this->linear_whitespace();
switch ($this->data[$this->position]) {
$this->state = self::STATE_VALUE;
$this->state = self::STATE_NEW_LINE;
$this->state = self::STATE_QUOTE_ESCAPED;
$this->state = self::STATE_QUOTE_CHAR;
* Parse a header value while within quotes
protected function quote_char()
$len = strcspn($this->data, "\x09\x20\x0A\"\\", $this->position);
$this->value .= substr($this->data, $this->position, $len);
$this->state = self::STATE_VALUE;
* Parse an escaped character within quotes
protected function quote_escaped()
$this->value .= $this->data[$this->position];
$this->state = self::STATE_QUOTE;
protected function body()
$this->body = substr($this->data, $this->position);
if (!empty($this->headers['transfer-encoding'])) {
unset($this->headers['transfer-encoding']);
$this->state = self::STATE_CHUNKED;
$this->state = self::STATE_EMIT;
* Parsed a "Transfer-Encoding: chunked" body
protected function chunked()
if (!preg_match('/^([0-9a-f]+)[^\r\n]*\r\n/i', trim($this->body))) {
$this->state = self::STATE_EMIT;
$is_chunked = (bool) preg_match('/^([0-9a-f]+)[^\r\n]*\r\n/i', $encoded, $matches);
// Looks like it's not chunked after all
$this->state = self::STATE_EMIT;
$length = hexdec(trim($matches[1]));
// For PHPStan: this will only be float when larger than PHP_INT_MAX.
// But even on 32-bit systems, it would mean 2GiB chunk, which sounds unlikely.
\assert(\is_int($length), "Length needs to be shorter than PHP_INT_MAX");
// Ignore trailer headers
$this->state = self::STATE_EMIT;
$chunk_length = strlen($matches[0]);
$decoded .= substr($encoded, $chunk_length, $length);
$encoded = substr($encoded, $chunk_length + $length + 2);
// BC for PHP < 8.0: substr() can return bool instead of string
$encoded = ($encoded === false) ? '' : $encoded;
if (trim($encoded) === '0' || empty($encoded)) {
$this->state = self::STATE_EMIT;
* Prepare headers (take care of proxies headers)