summaryrefslogtreecommitdiffstats
path: root/php-cve-2026-6735.patch
diff options
context:
space:
mode:
authorRemi Collet <remi@remirepo.net>2026-05-13 16:31:07 +0200
committerRemi Collet <remi@php.net>2026-05-13 16:31:07 +0200
commit11b950e90bcd9f0f3a7906cd3f2ae0c2e323f860 (patch)
tree625fdbf1da40e924f0daaf8e6a065643c9c08058 /php-cve-2026-6735.patch
parentdaf9b88c6cd0fe21b83f684e10dba7095d49605e (diff)
Fix XSS within status endpointHEADmaster
CVE-2026-6735 Fix Stale SOAP_GLOBAL(ref_map) pointer with Apache Map CVE-2026-6722 Fix Use-after-free after header parsing failure with SOAP_PERSISTENCE_SESSION CVE-2026-7261 Fix Broken Apache map value NULL check CVE-2026-7262 Fix Signed integer overflow of char array offset CVE-2026-7568
Diffstat (limited to 'php-cve-2026-6735.patch')
-rw-r--r--php-cve-2026-6735.patch2697
1 files changed, 2697 insertions, 0 deletions
diff --git a/php-cve-2026-6735.patch b/php-cve-2026-6735.patch
new file mode 100644
index 0000000..844bccb
--- /dev/null
+++ b/php-cve-2026-6735.patch
@@ -0,0 +1,2697 @@
+From 382b1902c46ae1965b7973b801d007366a7851c2 Mon Sep 17 00:00:00 2001
+From: Jakub Zelenka <bukka@php.net>
+Date: Sun, 3 May 2026 20:01:41 +0200
+Subject: [PATCH 5/6] GHSA-7qg2-v9fj-4mwv: [fpm] XSS within status endpoint
+
+Fixes GHSA-7qg2-v9fj-4mwv
+Fixes CVE-2026-6735
+
+(cherry picked from commit 99a5ad7441de9914246c7863adb6997396008b9d)
+(cherry picked from commit cc2960e782eb5cc262d7bd572a7d18979a811954)
+(cherry picked from commit 62daef7b73108ceda2545862cde0673f252ba2d2)
+(cherry picked from commit aeaf48ca0bceba42b9595dff30d9e96029c54613)
+
+backport some new FPM tester features
+
+(cherry picked from commit 8b1746466f9fcf248f9879fabfa356106d365da0)
+(cherry picked from commit 8e0efa0f20484c8bbfdb8671d61b232b70e2bd0a)
+(cherry picked from commit 9be5be215954c010087134964d4fd3ab907cdbda)
+---
+ sapi/fpm/fpm/fpm_status.c | 31 +-
+ sapi/fpm/tests/fcgi.inc | 192 ++-
+ .../tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt | 48 +
+ sapi/fpm/tests/logtool.inc | 476 ++++++
+ sapi/fpm/tests/response.inc | 281 ++++
+ sapi/fpm/tests/tester.inc | 1279 +++++++++++++++++
+ 6 files changed, 2244 insertions(+), 63 deletions(-)
+ create mode 100644 sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt
+ create mode 100644 sapi/fpm/tests/logtool.inc
+ create mode 100644 sapi/fpm/tests/response.inc
+ create mode 100644 sapi/fpm/tests/tester.inc
+
+diff --git a/sapi/fpm/fpm/fpm_status.c b/sapi/fpm/fpm/fpm_status.c
+index 666a1d9aba5..9693f0342ae 100644
+--- a/sapi/fpm/fpm/fpm_status.c
++++ b/sapi/fpm/fpm/fpm_status.c
+@@ -386,9 +386,10 @@ int fpm_status_handle_request(void) /* {{{ */
+
+ /* no need to test the var 'full' */
+ if (full_syntax) {
+- int i, first;
+- zend_string *tmp_query_string;
+- char *query_string;
++ unsigned int i;
++ int first;
++ zend_string *tmp_query_string, *tmp_request_uri_string;
++ char *query_string, *request_uri_string;
+ struct timeval duration, now;
+ #ifdef HAVE_FPM_LQ
+ float cpu;
+@@ -415,13 +416,30 @@ int fpm_status_handle_request(void) /* {{{ */
+ }
+ }
+
++ request_uri_string = NULL;
++ tmp_request_uri_string = NULL;
++ if (proc.request_uri[0] != '\0') {
++ if (encode) {
++ tmp_request_uri_string = php_escape_html_entities_ex(
++ (unsigned char*)proc.request_uri,
++ strlen(proc.request_uri), 1, ENT_DISALLOWED | ENT_HTML_DOC_XML1 | ENT_COMPAT,
++ NULL, /* double_encode */ 1);
++ request_uri_string = ZSTR_VAL(tmp_request_uri_string);
++ } else {
++ request_uri_string = proc.request_uri;
++ }
++ }
++
+ query_string = NULL;
+ tmp_query_string = NULL;
+ if (proc.query_string[0] != '\0') {
+ if (!encode) {
+ query_string = proc.query_string;
+ } else {
+- tmp_query_string = php_escape_html_entities_ex((unsigned char *)proc.query_string, strlen(proc.query_string), 1, ENT_HTML_IGNORE_ERRORS & ENT_COMPAT, NULL, 1);
++ tmp_query_string = php_escape_html_entities_ex(
++ (unsigned char*)proc.query_string,
++ strlen(proc.query_string), 1, ENT_DISALLOWED | ENT_HTML_DOC_XML1 | ENT_COMPAT,
++ NULL, /* double_encode */ 1);
+ query_string = ZSTR_VAL(tmp_query_string);
+ }
+ }
+@@ -449,7 +467,7 @@ int fpm_status_handle_request(void) /* {{{ */
+ proc.requests,
+ duration.tv_sec * 1000000UL + duration.tv_usec,
+ proc.request_method[0] != '\0' ? proc.request_method : "-",
+- proc.request_uri[0] != '\0' ? proc.request_uri : "-",
++ request_uri_string ? request_uri_string : "-",
+ query_string ? "?" : "",
+ query_string ? query_string : "",
+ proc.content_length,
+@@ -462,6 +480,9 @@ int fpm_status_handle_request(void) /* {{{ */
+ PUTS(buffer);
+ efree(buffer);
+
++ if (tmp_request_uri_string) {
++ zend_string_free(tmp_request_uri_string);
++ }
+ if (tmp_query_string) {
+ zend_string_free(tmp_query_string);
+ }
+diff --git a/sapi/fpm/tests/fcgi.inc b/sapi/fpm/tests/fcgi.inc
+index b31676260da..07603a808e0 100644
+--- a/sapi/fpm/tests/fcgi.inc
++++ b/sapi/fpm/tests/fcgi.inc
+@@ -72,25 +72,25 @@ class Client
+
+ /**
+ * Socket
+- * @var Resource
++ * @var resource
+ */
+ private $_sock = null;
+
+ /**
+ * Host
+- * @var String
++ * @var string
+ */
+ private $_host = null;
+
+ /**
+ * Port
+- * @var Integer
++ * @var int
+ */
+ private $_port = null;
+
+ /**
+ * Keep Alive
+- * @var Boolean
++ * @var bool
+ */
+ private $_keepAlive = false;
+
+@@ -110,27 +110,27 @@ class Client
+
+ /**
+ * Use persistent sockets to connect to backend
+- * @var Boolean
++ * @var bool
+ */
+ private $_persistentSocket = false;
+
+ /**
+ * Connect timeout in milliseconds
+- * @var Integer
++ * @var int
+ */
+ private $_connectTimeout = 5000;
+
+ /**
+ * Read/Write timeout in milliseconds
+- * @var Integer
++ * @var int
+ */
+ private $_readWriteTimeout = 5000;
+
+ /**
+ * Constructor
+ *
+- * @param String $host Host of the FastCGI application
+- * @param Integer $port Port of the FastCGI application
++ * @param string $host Host of the FastCGI application
++ * @param int $port Port of the FastCGI application
+ */
+ public function __construct($host, $port)
+ {
+@@ -138,15 +138,25 @@ class Client
+ $this->_port = $port;
+ }
+
++ /**
++ * Get host.
++ *
++ * @return string
++ */
++ public function getHost()
++ {
++ return $this->_host;
++ }
++
+ /**
+ * Define whether or not the FastCGI application should keep the connection
+ * alive at the end of a request
+ *
+- * @param Boolean $b true if the connection should stay alive, false otherwise
++ * @param bool $b true if the connection should stay alive, false otherwise
+ */
+ public function setKeepAlive($b)
+ {
+- $this->_keepAlive = (boolean)$b;
++ $this->_keepAlive = (bool)$b;
+ if (!$this->_keepAlive && $this->_sock) {
+ fclose($this->_sock);
+ }
+@@ -155,7 +165,7 @@ class Client
+ /**
+ * Get the keep alive status
+ *
+- * @return Boolean true if the connection should stay alive, false otherwise
++ * @return bool true if the connection should stay alive, false otherwise
+ */
+ public function getKeepAlive()
+ {
+@@ -166,12 +176,12 @@ class Client
+ * Define whether or not PHP should attempt to re-use sockets opened by previous
+ * request for efficiency
+ *
+- * @param Boolean $b true if persistent socket should be used, false otherwise
++ * @param bool $b true if persistent socket should be used, false otherwise
+ */
+ public function setPersistentSocket($b)
+ {
+ $was_persistent = ($this->_sock && $this->_persistentSocket);
+- $this->_persistentSocket = (boolean)$b;
++ $this->_persistentSocket = (bool)$b;
+ if (!$this->_persistentSocket && $was_persistent) {
+ fclose($this->_sock);
+ }
+@@ -180,7 +190,7 @@ class Client
+ /**
+ * Get the pesistent socket status
+ *
+- * @return Boolean true if the socket should be persistent, false otherwise
++ * @return bool true if the socket should be persistent, false otherwise
+ */
+ public function getPersistentSocket()
+ {
+@@ -191,7 +201,7 @@ class Client
+ /**
+ * Set the connect timeout
+ *
+- * @param Integer number of milliseconds before connect will timeout
++ * @param int number of milliseconds before connect will timeout
+ */
+ public function setConnectTimeout($timeoutMs)
+ {
+@@ -201,7 +211,7 @@ class Client
+ /**
+ * Get the connect timeout
+ *
+- * @return Integer number of milliseconds before connect will timeout
++ * @return int number of milliseconds before connect will timeout
+ */
+ public function getConnectTimeout()
+ {
+@@ -211,7 +221,7 @@ class Client
+ /**
+ * Set the read/write timeout
+ *
+- * @param Integer number of milliseconds before read or write call will timeout
++ * @param int number of milliseconds before read or write call will timeout
+ */
+ public function setReadWriteTimeout($timeoutMs)
+ {
+@@ -222,7 +232,7 @@ class Client
+ /**
+ * Get the read timeout
+ *
+- * @return Integer number of milliseconds before read will timeout
++ * @return int number of milliseconds before read will timeout
+ */
+ public function getReadWriteTimeout()
+ {
+@@ -232,14 +242,18 @@ class Client
+ /**
+ * Helper to avoid duplicating milliseconds to secs/usecs in a few places
+ *
+- * @param Integer millisecond timeout
+- * @return Boolean
++ * @param int millisecond timeout
++ * @return bool
+ */
+ private function set_ms_timeout($timeoutMs) {
+ if (!$this->_sock) {
+ return false;
+ }
+- return stream_set_timeout($this->_sock, floor($timeoutMs / 1000), ($timeoutMs % 1000) * 1000);
++ return stream_set_timeout(
++ $this->_sock,
++ floor($timeoutMs / 1000),
++ ($timeoutMs % 1000) * 1000
++ );
+ }
+
+
+@@ -250,9 +264,21 @@ class Client
+ {
+ if (!$this->_sock) {
+ if ($this->_persistentSocket) {
+- $this->_sock = pfsockopen($this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout/1000);
++ $this->_sock = pfsockopen(
++ $this->_host,
++ $this->_port,
++ $errno,
++ $errstr,
++ $this->_connectTimeout/1000
++ );
+ } else {
+- $this->_sock = fsockopen($this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout/1000);
++ $this->_sock = fsockopen(
++ $this->_host,
++ $this->_port,
++ $errno,
++ $errstr,
++ $this->_connectTimeout/1000
++ );
+ }
+
+ if (!$this->_sock) {
+@@ -268,9 +294,10 @@ class Client
+ /**
+ * Build a FastCGI packet
+ *
+- * @param Integer $type Type of the packet
+- * @param String $content Content of the packet
+- * @param Integer $requestId RequestId
++ * @param int $type Type of the packet
++ * @param string $content Content of the packet
++ * @param int $requestId RequestId
++ * @return string
+ */
+ private function buildPacket($type, $content, $requestId = 1)
+ {
+@@ -289,9 +316,9 @@ class Client
+ /**
+ * Build an FastCGI Name value pair
+ *
+- * @param String $name Name
+- * @param String $value Value
+- * @return String FastCGI Name value pair
++ * @param string $name Name
++ * @param string $value Value
++ * @return string FastCGI Name value pair
+ */
+ private function buildNvpair($name, $value)
+ {
+@@ -302,14 +329,16 @@ class Client
+ $nvpair = chr($nlen);
+ } else {
+ /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
+- $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
++ $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF)
++ . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
+ }
+ if ($vlen < 128) {
+ /* valueLengthB0 */
+ $nvpair .= chr($vlen);
+ } else {
+ /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
+- $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
++ $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF)
++ . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
+ }
+ /* nameData & valueData */
+ return $nvpair . $name . $value;
+@@ -318,7 +347,7 @@ class Client
+ /**
+ * Read a set of FastCGI Name value pairs
+ *
+- * @param String $data Data containing the set of FastCGI NVPair
++ * @param string $data Data containing the set of FastCGI NVPair
+ * @return array of NVPair
+ */
+ private function readNvpair($data, $length = null)
+@@ -357,7 +386,7 @@ class Client
+ /**
+ * Decode a FastCGI Packet
+ *
+- * @param String $data String containing all the packet
++ * @param string $data string containing all the packet
+ * @return array
+ */
+ private function decodePacketHeader($data)
+@@ -403,6 +432,7 @@ class Client
+ *
+ * @param array $requestedInfo information to retrieve
+ * @return array
++ * @throws \Exception
+ */
+ public function getValues(array $requestedInfo)
+ {
+@@ -423,11 +453,14 @@ class Client
+ }
+
+ /**
+- * Execute a request to the FastCGI application
++ * Execute a request to the FastCGI application and return response body
+ *
+ * @param array $params Array of parameters
+- * @param String $stdin Content
+- * @return String
++ * @param string $stdin Content
++ * @return string
++ * @throws ForbiddenException
++ * @throws TimedOutException
++ * @throws \Exception
+ */
+ public function request(array $params, $stdin)
+ {
+@@ -435,20 +468,38 @@ class Client
+ return $this->wait_for_response($id);
+ }
+
++ /**
++ * Execute a request to the FastCGI application and return request data
++ *
++ * @param array $params Array of parameters
++ * @param string $stdin Content
++ * @return array
++ * @throws ForbiddenException
++ * @throws TimedOutException
++ * @throws \Exception
++ */
++ public function request_data(array $params, $stdin)
++ {
++ $id = $this->async_request($params, $stdin);
++ return $this->wait_for_response_data($id);
++ }
++
+ /**
+ * Execute a request to the FastCGI application asyncronously
+- *
++ *
+ * This sends request to application and returns the assigned ID for that request.
+ *
+ * You should keep this id for later use with wait_for_response(). Ids are chosen randomly
+- * rather than seqentially to guard against false-positives when using persistent sockets.
+- * In that case it is possible that a delayed response to a request made by a previous script
+- * invocation comes back on this socket and is mistaken for response to request made with same ID
+- * during this request.
++ * rather than sequentially to guard against false-positives when using persistent sockets.
++ * In that case it is possible that a delayed response to a request made by a previous script
++ * invocation comes back on this socket and is mistaken for response to request made with same
++ * ID during this request.
+ *
+ * @param array $params Array of parameters
+- * @param String $stdin Content
+- * @return Integer
++ * @param string $stdin Content
++ * @return int
++ * @throws TimedOutException
++ * @throws \Exception
+ */
+ public function async_request(array $params, $stdin)
+ {
+@@ -460,10 +511,12 @@ class Client
+ // Using persistent sockets implies you want them keept alive by server!
+ $keepAlive = intval($this->_keepAlive || $this->_persistentSocket);
+
+- $request = $this->buildPacket(self::BEGIN_REQUEST
+- ,chr(0) . chr(self::RESPONDER) . chr($keepAlive) . str_repeat(chr(0), 5)
+- ,$id
+- );
++ $request = $this->buildPacket(
++ self::BEGIN_REQUEST,
++ chr(0) . chr(self::RESPONDER) . chr($keepAlive)
++ . str_repeat(chr(0), 5),
++ $id
++ );
+
+ $paramsRequest = '';
+ foreach ($params as $key => $value) {
+@@ -494,21 +547,26 @@ class Client
+
+ $this->_requests[$id] = array(
+ 'state' => self::REQ_STATE_WRITTEN,
+- 'response' => null
++ 'response' => null,
++ 'err_response' => null,
++ 'out_response' => null,
+ );
+
+ return $id;
+ }
+
+ /**
+- * Blocking call that waits for response to specific request
+- *
+- * @param Integer $requestId
+- * @param Integer $timeoutMs [optional] the number of milliseconds to wait. Defaults to the ReadWriteTimeout value set.
+- * @return string response body
++ * Blocking call that waits for response data of the specific request
++ *
++ * @param int $requestId
++ * @param int $timeoutMs [optional] the number of milliseconds to wait.
++ * @return array response data
++ * @throws ForbiddenException
++ * @throws TimedOutException
++ * @throws \Exception
+ */
+- public function wait_for_response($requestId, $timeoutMs = 0) {
+-
++ public function wait_for_response_data($requestId, $timeoutMs = 0)
++ {
+ if (!isset($this->_requests[$requestId])) {
+ throw new \Exception('Invalid request id given');
+ }
+@@ -537,12 +595,15 @@ class Client
+ if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) {
+ if ($resp['type'] == self::STDERR) {
+ $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_ERR;
++ $this->_requests[$resp['requestId']]['err_response'] .= $resp['content'];
++ } else {
++ $this->_requests[$resp['requestId']]['out_response'] .= $resp['content'];
+ }
+ $this->_requests[$resp['requestId']]['response'] .= $resp['content'];
+ }
+ if ($resp['type'] == self::END_REQUEST) {
+ $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_OK;
+- if ($resp['requestId'] == $requestId) {
++ if ($resp['requestId'] == $requestId) {
+ break;
+ }
+ }
+@@ -586,7 +647,22 @@ class Client
+ throw new \Exception('Role value not known [UNKNOWN_ROLE]');
+ break;
+ case self::REQUEST_COMPLETE:
+- return $this->_requests[$requestId]['response'];
++ return $this->_requests[$requestId];
+ }
+ }
++
++ /**
++ * Blocking call that waits for response to specific request
++ *
++ * @param int $requestId
++ * @param int $timeoutMs [optional] the number of milliseconds to wait.
++ * @return string The response content.
++ * @throws ForbiddenException
++ * @throws TimedOutException
++ * @throws \Exception
++ */
++ public function wait_for_response($requestId, $timeoutMs = 0)
++ {
++ return $this->wait_for_response_data($requestId, $timeoutMs)['response'];
++ }
+ }
+diff --git a/sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt b/sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt
+new file mode 100644
+index 00000000000..475bc130a42
+--- /dev/null
++++ b/sapi/fpm/tests/ghsa-7qg2-v9fj-4mwv-status-xss.phpt
+@@ -0,0 +1,48 @@
++--TEST--
++FPM: GHSA-7qg2-v9fj-4mwv - status xss
++--SKIPIF--
++<?php include "skipif.inc"; ?>
++--FILE--
++<?php
++
++require_once "tester.inc";
++
++$cfg = <<<EOT
++[global]
++error_log = {{FILE:LOG}}
++[unconfined]
++listen = {{ADDR}}
++pm = static
++pm.max_children = 2
++pm.status_path = /status
++catch_workers_output = yes
++EOT;
++
++$code = <<<EOT
++<?php
++usleep(200000);
++EOT;
++
++$tester = new FPM\Tester($cfg, $code);
++$tester->start();
++$tester->expectLogStartNotices();
++$responses = $tester
++ ->multiRequest([
++ ['uri' => '/<script>alert(1)</script>', 'query' => '<script>alert(2)</script>'],
++ ['uri' => '/status', 'query' => 'full&html', 'delay' => 100000],
++ ]);
++var_dump(strpos($responses[1]->getBody(), '<script>'));
++$tester->terminate();
++$tester->expectLogTerminatingNotices();
++$tester->close();
++
++?>
++Done
++--EXPECT--
++bool(false)
++Done
++--CLEAN--
++<?php
++require_once "tester.inc";
++FPM\Tester::clean();
++?>
+diff --git a/sapi/fpm/tests/logtool.inc b/sapi/fpm/tests/logtool.inc
+new file mode 100644
+index 00000000000..219c6fedbb8
+--- /dev/null
++++ b/sapi/fpm/tests/logtool.inc
+@@ -0,0 +1,476 @@
++<?php
++
++namespace FPM;
++
++class LogTool
++{
++ const P_TIME = '\[\d\d-\w\w\w-\d{4} \d\d:\d\d:\d\d\]';
++ const P_PREFIX = '\[pool unconfined\] child \d+ said into stderr: ';
++ const FINAL_SUFFIX = ', pipe is closed';
++
++ /**
++ * @var string
++ */
++ private $message;
++
++ /**
++ * @var string
++ */
++ private $level;
++
++ /**
++ * @var int
++ */
++ private $position;
++
++ /**
++ * @var int
++ */
++ private $suffixPosition;
++
++ /**
++ * @var int
++ */
++ private $limit;
++
++ /**
++ * @var string
++ */
++ private $pattern;
++
++ /**
++ * @var string
++ */
++ private $error;
++
++ /**
++ * @param string $message
++ * @param int $limit
++ * @param int $repeat
++ */
++ public function setExpectedMessage(string $message, int $limit, int $repeat = 0)
++ {
++ $this->message = ($repeat > 0) ? str_repeat($message, $repeat) : $message;
++ $this->limit = $limit;
++ $this->position = 0;
++ }
++
++ /**
++ * @param string $level
++ * @return int
++ */
++ public function setExpectedLevel(string $level)
++ {
++ return $this->level = $level;
++ }
++
++ /**
++ * @return string
++ */
++ public function getExpectedLevel(): string
++ {
++ return $this->level ?: 'WARNING';
++ }
++
++ /**
++ * @param string $line
++ * @return bool
++ */
++ public function checkTruncatedMessage(string $line)
++ {
++ if ($this->message === null) {
++ throw new \LogicException('The message has not been set');
++ }
++ $lineLen = strlen($line);
++ if (!$this->checkLineLength($line)) {
++ return false;
++ }
++ $this->pattern = '/^PHP message: (.*?)(\.\.\.)?$/';
++ if (preg_match($this->pattern, $line, $matches) === 0) {
++ return $this->error("Unexpected truncated message: {$line}");
++ }
++
++ if ($lineLen === $this->limit) {
++ if (!isset($matches[2])) {
++ return $this->error("The truncated line is not ended with '...'");
++ }
++ if (!$this->checkMessage($matches[1])) {
++ return false;
++ }
++ } else {
++ if (isset($matches[2])) {
++ // this is expecting that the expected message does not end with '...'
++ // which should not be an issue for the test purpose.
++ return $this->error("The line is complete and should not end with '...'");
++ }
++ if (!$this->checkMessage($matches[1], -1)) {
++ return false;
++ }
++ }
++
++ return true;
++ }
++
++ /**
++ * @param array $lines
++ * @param bool $terminated
++ * @param bool $decorated
++ * @return bool
++ */
++ public function checkWrappedMessage(array $lines, bool $terminated = true, bool $decorated = true)
++ {
++ if ($this->message === null) {
++ throw new \LogicException('The message has not been set');
++ }
++ if ($decorated) {
++ $this->pattern = sprintf(
++ '/^(%s %s: %s)"([^"]*)"(.*)?$/',
++ self::P_TIME,
++ $this->getExpectedLevel(),
++ self::P_PREFIX
++ );
++ } else {
++ $this->pattern = null;
++ }
++
++ $idx = 0;
++ foreach ($lines as $idx => $line) {
++ if (!$this->checkLine($line)) {
++ break;
++ }
++ }
++
++ if ($this->suffixPosition > 0) {
++ $suffixPattern = sprintf(
++ '/^%s %s: %s(.*)$/',
++ self::P_TIME, $this->getExpectedLevel(),
++ self::P_PREFIX
++ );
++ $line = $lines[++$idx];
++ if (preg_match($suffixPattern, $line, $matches) === 0) {
++ return $this->error("Unexpected line: $line");
++ }
++ if ($matches[1] !== substr(self::FINAL_SUFFIX, $this->suffixPosition)) {
++ return $this->error(
++ "The suffix has not been finished from position $this->suffixPosition in line: $line"
++ );
++ }
++ }
++
++ if ($terminated) {
++ return $this->expectTerminatorLines($lines, $idx);
++ }
++
++ return true;
++ }
++
++ /**
++ * @param string $line
++ * @return bool
++ */
++ private function checkLine(string $line)
++ {
++ if ($this->pattern === null) {
++ // plain (not decorated) output
++ $out = rtrim($line);
++ $finalSuffix = null;
++ } elseif (($res = preg_match($this->pattern, $line, $matches)) > 0) {
++ $out = $matches[2];
++ $finalSuffix = $matches[3] ?? false;
++ } else {
++ return $this->error("Unexpected line: $line");
++ }
++
++ $rem = strlen($this->message) - $this->position;
++ $lineLen = strlen($line);
++ if (!$this->checkLineLength($line, $lineLen)) {
++ return false;
++ }
++ if (!$this->checkMessage($out, $this->position)) {
++ return false;
++ }
++ $outLen = strlen($out);
++ if ($rem > $outLen) { // continuous line
++ if ($lineLen !== $this->limit) {
++ if ($lineLen + ($rem - $outLen) < $this->limit) {
++ return $this->error("Printed less than the message len");
++ }
++ return $this->error(
++ "The continuous line length is $lineLen but it should equal to limit $this->limit"
++ );
++ }
++ $this->position += $outLen;
++ return true;
++ }
++ if ($rem !== $outLen) {
++ return $this->error("Printed more than the message len");
++ }
++ if ($finalSuffix === null || $finalSuffix === "") {
++ return false;
++ }
++ if ($finalSuffix === false) {
++ return $this->error("No final suffix");
++ }
++ if (strpos(self::FINAL_SUFFIX, $finalSuffix) === false) {
++ return $this->error("The final suffix has to be equal to ', pipe is closed'");
++ }
++ if (self::FINAL_SUFFIX !== $finalSuffix) {
++ $this->suffixPosition = strlen($finalSuffix);
++ }
++ // complete final suffix printed
++ return false;
++ }
++
++ /**
++ * @param string $line
++ * @param int $lineLen
++ * @return bool
++ */
++ private function checkLineLength(string $line, $lineLen = null) {
++ $lineLen = $lineLen ?: strlen($line);
++ if ($lineLen > $this->limit) {
++ return $this->error(
++ "The line length is $lineLen which is higher than limit $this->limit"
++ );
++ }
++
++ return true;
++ }
++
++ /**
++ * @param string $matchedMessage
++ * @param int $expectedMessageStart
++ * @return bool
++ */
++ private function checkMessage(string $matchedMessage, int $expectedMessageStart = 0)
++ {
++ if ($expectedMessageStart < 0) {
++ $expectedMessage = $this->message;
++ } else {
++ $expectedMessage = substr($this->message, $expectedMessageStart, strlen($matchedMessage));
++ }
++ if ($expectedMessage !== $matchedMessage) {
++ return $this->error(
++ sprintf(
++ "The actual string(%d) does not match expected string(%d):\n",
++ strlen($matchedMessage),
++ strlen($expectedMessage)
++ ) .
++ "- EXPECT: '$expectedMessage'\n" .
++ "- ACTUAL: '$matchedMessage'"
++ );
++ }
++
++ return true;
++ }
++
++ /**
++ * @param array $lines
++ * @return bool
++ */
++ public function expectStartingLines(array $lines)
++ {
++ if ($this->getError()) {
++ return false;
++ }
++
++ if (count($lines) < 2) {
++ return $this->error("No starting lines");
++ }
++
++ return (
++ $this->expectNotice($lines[0], 'fpm is running, pid \d+') &&
++ $this->expectNotice($lines[1], 'ready to handle connections')
++ );
++ }
++
++ /**
++ * @param array $lines
++ * @param int $idx
++ * @return bool
++ */
++ public function expectTerminatorLines(array $lines, int $idx = -1)
++ {
++ if ($this->getError()) {
++ return false;
++ }
++
++ if (count($lines) - $idx < 3) {
++ return $this->error("No terminating lines");
++ }
++
++ return (
++ $this->expectNotice($lines[++$idx], 'Terminating ...') &&
++ $this->expectNotice($lines[++$idx], 'exiting, bye-bye!')
++ );
++ }
++
++ /**
++ * @param string $type
++ * @param string $line
++ * @param string $expectedMessage
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectEntry(string $type, string $line, string $expectedMessage, $pool = null)
++ {
++ if ($this->getError()) {
++ return false;
++ }
++ if ($pool !== null) {
++ $expectedMessage = '\[pool ' . $pool . '\] ' . $expectedMessage;
++ }
++
++ $line = rtrim($line);
++ $pattern = sprintf('/^%s %s: %s$/', self::P_TIME, $type, $expectedMessage);
++
++ if (preg_match($pattern, $line, $matches) === 0) {
++ return $this->error(
++ "The $type does not match expected message:\n" .
++ "- PATTERN: $pattern\n" .
++ "- MESSAGE: $line\n" .
++ "- EXPECT: '$expectedMessage'\n" .
++ "- ACTUAL: '" . substr($line, strpos($line, $type) + strlen($type) + 2) . "'"
++ );
++ }
++
++ return true;
++ }
++
++ /**
++ * @param string $line
++ * @param string $expectedMessage
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectDebug(string $line, string $expectedMessage, $pool = null)
++ {
++ return $this->expectEntry('DEBUG', $line, $expectedMessage, $pool);
++ }
++
++ /**
++ * @param string $line
++ * @param string $expectedMessage
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectNotice(string $line, string $expectedMessage, $pool = null)
++ {
++ return $this->expectEntry('NOTICE', $line, $expectedMessage, $pool);
++ }
++
++ /**
++ * @param string $line
++ * @param string $expectedMessage
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectWarning(string $line, string $expectedMessage, $pool = null)
++ {
++ return $this->expectEntry('WARNING', $line, $expectedMessage, $pool);
++ }
++
++ /**
++ * @param string $line
++ * @param string $expectedMessage
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectError(string $line, string $expectedMessage, $pool = null)
++ {
++ return $this->expectEntry('ERROR', $line, $expectedMessage, $pool);
++ }
++
++ /**
++ * @param string $line
++ * @param string $expectedMessage
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectAlert(string $line, string $expectedMessage, $pool = null)
++ {
++ return $this->expectEntry('ALERT', $line, $expectedMessage, $pool);
++ }
++
++
++ /**
++ * @param string $msg
++ * @return bool
++ */
++ private function error(string $msg)
++ {
++ $this->error = $msg;
++ echo "ERROR: $msg\n";
++ return false;
++ }
++
++ /**
++ * @return string
++ */
++ public function getError()
++ {
++ return $this->error;
++ }
++}
++
++if (isset($argv[1]) && $argv[1] === 'logtool-selftest') {
++ $cases = [
++ [
++ 'limit' => 1050,
++ 'lines' => [
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: "' .
++ str_repeat('a', 968) . '"',
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: "' .
++ str_repeat('a', 968) . '"',
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: "' .
++ str_repeat('a', 112) . '", pipe is closed',
++ '[08-Oct-2017 19:53:55] NOTICE: Terminating ...',
++ '[08-Oct-2017 19:53:55] NOTICE: exiting, bye-bye!',
++ ],
++ 'message' => str_repeat('a', 2048),
++ 'type' => 'stdio',
++ ],
++ [
++ 'limit' => 1050,
++ 'lines' => [
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: "' .
++ str_repeat('a', 968) . '"',
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: "' .
++ str_repeat('a', 968) . '"',
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: "' .
++ str_repeat('a', 964) . '", pi',
++ '[08-Oct-2017 19:53:50] WARNING: [pool unconfined] child 23183 said into stderr: pe is closed',
++ '[08-Oct-2017 19:53:55] NOTICE: Terminating ...',
++ '[08-Oct-2017 19:53:55] NOTICE: exiting, bye-bye!',
++ ],
++ 'message' => str_repeat('a', 2900),
++ 'type' => 'stdio',
++ ],
++ [
++ 'limit' => 1024,
++ 'line' => '[08-Oct-2017 19:53:50] WARNING: ' . str_repeat('a',989) . '...',
++ 'message' => str_repeat('a', 2900),
++ 'type' => 'message',
++ ],
++ [
++ 'limit' => 1024,
++ 'line' => '[08-Oct-2017 19:53:50] WARNING: ' . str_repeat('a',20),
++ 'message' => str_repeat('a', 20),
++ 'type' => 'message',
++ ],
++ ];
++ foreach ($cases as $case) {
++ printf("Test message with len %d and limit %d: ", strlen($case['message']), $case['limit']);
++ $logTool = new LogTool();
++ $logTool->setExpectedMessage($case['message'], $case['limit']);
++ if ($case['type'] === 'stdio') {
++ $logTool->checkWrappedMessage($case['lines']);
++ } else {
++ $logTool->checkTruncatedMessage($case['line']);
++ }
++ if (!$logTool->getError()) {
++ echo "OK\n";
++ }
++ }
++ echo "Done\n";
++}
+diff --git a/sapi/fpm/tests/response.inc b/sapi/fpm/tests/response.inc
+new file mode 100644
+index 00000000000..24285bf560c
+--- /dev/null
++++ b/sapi/fpm/tests/response.inc
+@@ -0,0 +1,281 @@
++<?php
++
++namespace FPM;
++
++class Response
++{
++ const HEADER_SEPARATOR = "\r\n\r\n";
++
++ /**
++ * @var array
++ */
++ private $data;
++
++ /**
++ * @var string
++ */
++ private $rawData;
++
++ /**
++ * @var string
++ */
++ private $rawHeaders;
++
++ /**
++ * @var string
++ */
++ private $rawBody;
++
++ /**
++ * @var array
++ */
++ private $headers;
++
++ /**
++ * @var bool
++ */
++ private $valid;
++
++ /**
++ * @var bool
++ */
++ private $expectInvalid;
++
++ /**
++ * @param string|array|null $data
++ * @param bool $expectInvalid
++ */
++ public function __construct($data = null, $expectInvalid = false)
++ {
++ if (!is_array($data)) {
++ $data = [
++ 'response' => $data,
++ 'err_response' => null,
++ 'out_response' => $data,
++ ];
++ }
++
++ $this->data = $data;
++ $this->expectInvalid = $expectInvalid;
++ }
++
++ /**
++ * @param mixed $body
++ * @param string $contentType
++ * @return Response
++ */
++ public function expectBody($body, $contentType = 'text/html')
++ {
++ if ($multiLine = is_array($body)) {
++ $body = implode("\n", $body);
++ }
++
++ if (
++ $this->checkIfValid() &&
++ $this->checkDefaultHeaders($contentType) &&
++ $body !== $this->rawBody
++ ) {
++ if ($multiLine) {
++ $this->error(
++ "==> The expected body:\n$body\n" .
++ "==> does not match the actual body:\n$this->rawBody"
++ );
++ } else {
++ $this->error(
++ "The expected body '$body' does not match actual body '$this->rawBody'"
++ );
++ }
++ }
++
++ return $this;
++ }
++
++ /**
++ * @return Response
++ */
++ public function expectEmptyBody()
++ {
++ return $this->expectBody('');
++ }
++
++ /**
++ * @param string $contentType
++ * @return string|null
++ */
++ public function getBody($contentType = 'text/html')
++ {
++ if ($this->checkIfValid() && $this->checkDefaultHeaders($contentType)) {
++ return $this->rawBody;
++ }
++
++ return null;
++ }
++
++ /**
++ * Print raw body
++ */
++ public function dumpBody()
++ {
++ var_dump($this->getBody());
++ }
++
++ /**
++ * Print raw body
++ */
++ public function printBody()
++ {
++ echo $this->getBody();
++ }
++
++ /**
++ * Debug response output
++ */
++ public function debugOutput()
++ {
++ echo "-------------- RESPONSE: --------------\n";
++ echo "OUT:\n";
++ echo $this->data['out_response'];
++ echo "ERR:\n";
++ echo $this->data['err_response'];
++ echo "---------------------------------------\n\n";
++ }
++
++ /**
++ * @return string|null
++ */
++ public function getErrorData()
++ {
++ return $this->data['err_response'];
++ }
++
++ /**
++ * Check if the response is valid and if not emit error message
++ *
++ * @return bool
++ */
++ private function checkIfValid()
++ {
++ if ($this->isValid()) {
++ return true;
++ }
++
++ if (!$this->expectInvalid) {
++ $this->error("The response is invalid: $this->rawData");
++ }
++
++ return false;
++ }
++
++ /**
++ * @param string $contentType
++ * @return bool
++ */
++ private function checkDefaultHeaders($contentType)
++ {
++ // check default headers
++ return (
++ $this->checkHeader('X-Powered-By', '|^PHP/7|', true) &&
++ $this->checkHeader('Content-type', '|^' . $contentType . '(;\s?charset=\w+)?|', true)
++ );
++ }
++
++ /**
++ * @param string $name
++ * @param string $value
++ * @param bool $useRegex
++ * @return bool
++ */
++ private function checkHeader(string $name, string $value, $useRegex = false)
++ {
++ $lcName = strtolower($name);
++ $headers = $this->getHeaders();
++ if (!isset($headers[$lcName])) {
++ return $this->error("The header $name is not present");
++ }
++ $header = $headers[$lcName];
++
++ if (!$useRegex) {
++ if ($header === $value) {
++ return true;
++ }
++ return $this->error("The header $name value '$header' is not the same as '$value'");
++ }
++
++ if (!preg_match($value, $header)) {
++ return $this->error("The header $name value '$header' does not match RegExp '$value'");
++ }
++
++ return true;
++ }
++
++ /**
++ * @return array|null
++ */
++ private function getHeaders()
++ {
++ if (!$this->isValid()) {
++ return null;
++ }
++
++ if (is_array($this->headers)) {
++ return $this->headers;
++ }
++
++ $headerRows = explode("\r\n", $this->rawHeaders);
++ $headers = [];
++ foreach ($headerRows as $headerRow) {
++ $colonPosition = strpos($headerRow, ':');
++ if ($colonPosition === false) {
++ $this->error("Invalid header row (no colon): $headerRow");
++ }
++ $headers[strtolower(substr($headerRow, 0, $colonPosition))] = trim(
++ substr($headerRow, $colonPosition + 1)
++ );
++ }
++
++ return ($this->headers = $headers);
++ }
++
++ /**
++ * @return bool
++ */
++ private function isValid()
++ {
++ if ($this->valid === null) {
++ $this->processData();
++ }
++
++ return $this->valid;
++ }
++
++ /**
++ * Process data and set validity and raw data
++ */
++ private function processData()
++ {
++ $this->rawData = $this->data['out_response'];
++ $this->valid = (
++ !is_null($this->rawData) &&
++ strpos($this->rawData, self::HEADER_SEPARATOR)
++ );
++ if ($this->valid) {
++ list ($this->rawHeaders, $this->rawBody) = array_map(
++ 'trim',
++ explode(self::HEADER_SEPARATOR, $this->rawData)
++ );
++ }
++ }
++
++ /**
++ * Emit error message
++ *
++ * @param string $message
++ * @return bool
++ */
++ private function error($message)
++ {
++ echo "ERROR: $message\n";
++
++ return false;
++ }
++}
+diff --git a/sapi/fpm/tests/tester.inc b/sapi/fpm/tests/tester.inc
+new file mode 100644
+index 00000000000..c1384133f4f
+--- /dev/null
++++ b/sapi/fpm/tests/tester.inc
+@@ -0,0 +1,1279 @@
++<?php
++
++namespace FPM;
++
++use Adoy\FastCGI\Client;
++
++require_once 'fcgi.inc';
++require_once 'logtool.inc';
++require_once 'response.inc';
++
++class Tester
++{
++ /**
++ * Config directory for included files.
++ */
++ const CONF_DIR = __DIR__ . '/conf.d';
++
++ /**
++ * File extension for access log.
++ */
++ const FILE_EXT_LOG_ACC = 'acc.log';
++
++ /**
++ * File extension for error log.
++ */
++ const FILE_EXT_LOG_ERR = 'err.log';
++
++ /**
++ * File extension for slow log.
++ */
++ const FILE_EXT_LOG_SLOW = 'slow.log';
++
++ /**
++ * File extension for PID file.
++ */
++ const FILE_EXT_PID = 'pid';
++
++ /**
++ * @var array
++ */
++ static private $supportedFiles = [
++ self::FILE_EXT_LOG_ACC,
++ self::FILE_EXT_LOG_ERR,
++ self::FILE_EXT_LOG_SLOW,
++ self::FILE_EXT_PID,
++ 'src.php',
++ 'ini',
++ 'skip.ini',
++ '*.sock',
++ ];
++
++ /**
++ * @var array
++ */
++ static private $filesToClean = ['.user.ini'];
++
++ /**
++ * @var bool
++ */
++ private $debug;
++
++ /**
++ * @var array
++ */
++ private $clients;
++
++ /**
++ * @var LogTool
++ */
++ private $logTool;
++
++ /**
++ * Configuration template
++ *
++ * @var string
++ */
++ private $configTemplate;
++
++ /**
++ * The PHP code to execute
++ *
++ * @var string
++ */
++ private $code;
++
++ /**
++ * @var array
++ */
++ private $options;
++
++ /**
++ * @var string
++ */
++ private $fileName;
++
++ /**
++ * @var resource
++ */
++ private $masterProcess;
++
++ /**
++ * @var resource
++ */
++ private $outDesc;
++
++ /**
++ * @var array
++ */
++ private $ports = [];
++
++ /**
++ * @var string
++ */
++ private $error;
++
++ /**
++ * The last response for the request call
++ *
++ * @var Response
++ */
++ private $response;
++
++ /**
++ * Clean all the created files up
++ *
++ * @param int $backTraceIndex
++ */
++ static public function clean($backTraceIndex = 1)
++ {
++ $filePrefix = self::getCallerFileName($backTraceIndex);
++ if (substr($filePrefix, -6) === 'clean.') {
++ $filePrefix = substr($filePrefix, 0, -6);
++ }
++
++ $filesToClean = array_merge(
++ array_map(
++ function($fileExtension) use ($filePrefix) {
++ return $filePrefix . $fileExtension;
++ },
++ self::$supportedFiles
++ ),
++ array_map(
++ function($fileExtension) {
++ return __DIR__ . '/' . $fileExtension;
++ },
++ self::$filesToClean
++ )
++ );
++ // clean all the root files
++ foreach ($filesToClean as $filePattern) {
++ foreach (glob($filePattern) as $filePath) {
++ unlink($filePath);
++ }
++ }
++ // clean config files
++ if (is_dir(self::CONF_DIR)) {
++ foreach(glob(self::CONF_DIR . '/*.conf') as $name) {
++ unlink($name);
++ }
++ rmdir(self::CONF_DIR);
++ }
++ }
++
++ /**
++ * @param int $backTraceIndex
++ * @return string
++ */
++ static private function getCallerFileName($backTraceIndex = 1)
++ {
++ $backtrace = debug_backtrace();
++ if (isset($backtrace[$backTraceIndex]['file'])) {
++ $filePath = $backtrace[$backTraceIndex]['file'];
++ } else {
++ $filePath = __FILE__;
++ }
++
++ return substr($filePath, 0, -strlen(pathinfo($filePath, PATHINFO_EXTENSION)));
++ }
++
++ /**
++ * @return bool|string
++ */
++ static public function findExecutable()
++ {
++ $phpPath = getenv("TEST_PHP_EXECUTABLE");
++ for ($i = 0; $i < 2; $i++) {
++ $slashPosition = strrpos($phpPath, "/");
++ if ($slashPosition) {
++ $phpPath = substr($phpPath, 0, $slashPosition);
++ } else {
++ break;
++ }
++ }
++
++ if ($phpPath && is_dir($phpPath)) {
++ if (file_exists($phpPath."/fpm/php-fpm") && is_executable($phpPath."/fpm/php-fpm")) {
++ /* gotcha */
++ return $phpPath."/fpm/php-fpm";
++ }
++ $phpSbinFpmi = $phpPath."/sbin/php-fpm";
++ if (file_exists($phpSbinFpmi) && is_executable($phpSbinFpmi)) {
++ return $phpSbinFpmi;
++ }
++ }
++
++ // try local php-fpm
++ $fpmPath = dirname(__DIR__) . '/php-fpm';
++ if (file_exists($fpmPath) && is_executable($fpmPath)) {
++ return $fpmPath;
++ }
++
++ return false;
++ }
++
++ /**
++ * Skip test if any of the supplied files does not exist.
++ *
++ * @param mixed $files
++ */
++ static public function skipIfAnyFileDoesNotExist($files)
++ {
++ if (!is_array($files)) {
++ $files = array($files);
++ }
++ foreach ($files as $file) {
++ if (!file_exists($file)) {
++ die("skip File $file does not exist");
++ }
++ }
++ }
++
++ /**
++ * Skip test if config file is invalid.
++ *
++ * @param string $configTemplate
++ * @throws \Exception
++ */
++ static public function skipIfConfigFails(string $configTemplate)
++ {
++ $tester = new self($configTemplate, '', [], self::getCallerFileName());
++ $testResult = $tester->testConfig();
++ if ($testResult !== null) {
++ self::clean(2);
++ die("skip $testResult");
++ }
++ }
++
++ /**
++ * Skip test if IPv6 is not supported.
++ */
++ static public function skipIfIPv6IsNotSupported()
++ {
++ @stream_socket_client('tcp://[::1]:0', $errno);
++ if ($errno != 111) {
++ die('skip IPv6 is not supported.');
++ }
++ }
++
++ /**
++ * Skip if running on Travis.
++ *
++ * @param $message
++ */
++ static public function skipIfTravis($message)
++ {
++ if (getenv("TRAVIS")) {
++ die('skip Travis: ' . $message);
++ }
++ }
++
++ /**
++ * Tester constructor.
++ *
++ * @param string|array $configTemplate
++ * @param string $code
++ * @param array $options
++ * @param string $fileName
++ */
++ public function __construct(
++ $configTemplate,
++ string $code = '',
++ array $options = [],
++ $fileName = null
++ ) {
++ $this->configTemplate = $configTemplate;
++ $this->code = $code;
++ $this->options = $options;
++ $this->fileName = $fileName ?: self::getCallerFileName();
++ $this->logTool = new LogTool();
++ $this->debug = (bool) getenv('TEST_FPM_DEBUG');
++ }
++
++ /**
++ * @param string $ini
++ */
++ public function setUserIni(string $ini)
++ {
++ $iniFile = __DIR__ . '/.user.ini';
++ file_put_contents($iniFile, $ini);
++ }
++
++ /**
++ * Test configuration file.
++ *
++ * @return null|string
++ * @throws \Exception
++ */
++ public function testConfig()
++ {
++ $configFile = $this->createConfig();
++ $cmd = self::findExecutable() . ' -t -y ' . $configFile . ' 2>&1';
++ exec($cmd, $output, $code);
++ if ($code) {
++ return preg_replace("/\[.+?\]/", "", $output[0]);
++ }
++
++ return null;
++ }
++
++ /**
++ * Start PHP-FPM master process
++ *
++ * @param string $extraArgs
++ * @return bool
++ * @throws \Exception
++ */
++ public function start(string $extraArgs = '')
++ {
++ $configFile = $this->createConfig();
++ $desc = $this->outDesc ? [] : [1 => array('pipe', 'w')];
++ $asRoot = getenv('TEST_FPM_RUN_AS_ROOT') ? '--allow-to-run-as-root' : '';
++ $cmd = self::findExecutable() . " $asRoot -F -O -y $configFile $extraArgs";
++ /* Since it's not possible to spawn a process under linux without using a
++ * shell in php (why?!?) we need a little shell trickery, so that we can
++ * actually kill php-fpm */
++ $this->masterProcess = proc_open(
++ "killit () { kill \$child 2> /dev/null; }; " .
++ "trap killit TERM; $cmd 2>&1 & child=\$!; wait",
++ $desc,
++ $pipes
++ );
++ register_shutdown_function(
++ function($masterProcess) use($configFile) {
++ @unlink($configFile);
++ if (is_resource($masterProcess)) {
++ @proc_terminate($masterProcess);
++ while (proc_get_status($masterProcess)['running']) {
++ usleep(10000);
++ }
++ }
++ },
++ $this->masterProcess
++ );
++ if (!$this->outDesc !== false) {
++ $this->outDesc = $pipes[1];
++ }
++
++ return true;
++ }
++
++ /**
++ * Run until needle is found in the log.
++ *
++ * @param string $needle
++ * @param int $max
++ * @return bool
++ * @throws \Exception
++ */
++ public function runTill(string $needle, $max = 10)
++ {
++ $this->start();
++ $found = false;
++ for ($i = 0; $i < $max; $i++) {
++ $line = $this->getLogLine();
++ if (is_null($line)) {
++ break;
++ }
++ if (preg_match($needle, $line) === 1) {
++ $found = true;
++ break;
++ }
++ }
++ $this->close(true);
++
++ if (!$found) {
++ return $this->error("The search pattern not found");
++ }
++
++ return true;
++ }
++
++ /**
++ * Check if connection works.
++ *
++ * @param string $host
++ * @param null|string $successMessage
++ * @param null|string $errorMessage
++ * @param int $attempts
++ * @param int $delay
++ */
++ public function checkConnection(
++ $host = '127.0.0.1',
++ $successMessage = null,
++ $errorMessage = 'Connection failed',
++ $attempts = 20,
++ $delay = 50000
++ ) {
++ $i = 0;
++ do {
++ if ($i > 0 && $delay > 0) {
++ usleep($delay);
++ }
++ $fp = @fsockopen($host, $this->getPort());
++ } while ((++$i < $attempts) && !$fp);
++
++ if ($fp) {
++ $this->message($successMessage);
++ fclose($fp);
++ } else {
++ $this->message($errorMessage);
++ }
++ }
++
++
++ /**
++ * Execute request with parameters ordered for better checking.
++ *
++ * @param string $address
++ * @param string|null $successMessage
++ * @param string|null $errorMessage
++ * @param string $uri
++ * @param string $query
++ * @param array $headers
++ * @return Response
++ */
++ public function checkRequest(
++ string $address,
++ string $successMessage = null,
++ string $errorMessage = null,
++ $uri = '/ping',
++ $query = '',
++ $headers = []
++ ) {
++ return $this->request($query, $headers, $uri, $address, $successMessage, $errorMessage);
++ }
++
++ /**
++ * Execute and check ping request.
++ *
++ * @param string $address
++ * @param string $pingPath
++ * @param string $pingResponse
++ */
++ public function ping(
++ string $address = '{{ADDR}}',
++ string $pingResponse = 'pong',
++ string $pingPath = '/ping'
++ ) {
++ $response = $this->request('', [], $pingPath, $address);
++ $response->expectBody($pingResponse, 'text/plain');
++ }
++
++ /**
++ * Execute and check status request(s).
++ *
++ * @param array $expectedFields
++ * @param string|null $address
++ * @param string $statusPath
++ * @param mixed $formats
++ * @throws \Exception
++ */
++ public function status(
++ array $expectedFields,
++ string $address = null,
++ string $statusPath = '/status',
++ $formats = ['plain', 'html', 'xml', 'json']
++ ) {
++ if (!is_array($formats)) {
++ $formats = [$formats];
++ }
++
++ require_once "status.inc";
++ $status = new Status();
++ foreach ($formats as $format) {
++ $query = $format === 'plain' ? '' : $format;
++ $response = $this->request($query, [], $statusPath, $address);
++ $status->checkStatus($response, $expectedFields, $format);
++ }
++ }
++
++ /**
++ * Get request params array.
++ *
++ * @param string $query
++ * @param array $headers
++ * @param string|null $uri
++ * @param string|null $address
++ * @param string|null $successMessage
++ * @param string|null $errorMessage
++ * @param bool $connKeepAlive
++ * @return array
++ */
++ private function getRequestParams(
++ string $query = '',
++ array $headers = [],
++ string $uri = null
++ ) {
++ if (is_null($uri)) {
++ $uri = $this->makeSourceFile();
++ }
++
++ $params = array_merge(
++ [
++ 'GATEWAY_INTERFACE' => 'FastCGI/1.0',
++ 'REQUEST_METHOD' => 'GET',
++ 'SCRIPT_FILENAME' => $uri,
++ 'SCRIPT_NAME' => $uri,
++ 'QUERY_STRING' => $query,
++ 'REQUEST_URI' => $uri . ($query ? '?'.$query : ""),
++ 'DOCUMENT_URI' => $uri,
++ 'SERVER_SOFTWARE' => 'php/fcgiclient',
++ 'REMOTE_ADDR' => '127.0.0.1',
++ 'REMOTE_PORT' => '7777',
++ 'SERVER_ADDR' => '127.0.0.1',
++ 'SERVER_PORT' => '80',
++ 'SERVER_NAME' => php_uname('n'),
++ 'SERVER_PROTOCOL' => 'HTTP/1.1',
++ 'DOCUMENT_ROOT' => __DIR__,
++ 'CONTENT_TYPE' => '',
++ 'CONTENT_LENGTH' => 0
++ ],
++ $headers
++ );
++
++ return array_filter($params, function($value) {
++ return !is_null($value);
++ });
++ }
++
++ /**
++ * Execute request.
++ *
++ * @param string $query
++ * @param array $headers
++ * @param string|null $uri
++ * @param string|null $address
++ * @param string|null $successMessage
++ * @param string|null $errorMessage
++ * @param bool $connKeepAlive
++ * @return Response
++ */
++ public function request(
++ string $query = '',
++ array $headers = [],
++ string $uri = null,
++ string $address = null,
++ string $successMessage = null,
++ string $errorMessage = null,
++ bool $connKeepAlive = false
++ ) {
++ if ($this->hasError()) {
++ return new Response(null, true);
++ }
++ if (is_null($uri)) {
++ $uri = $this->makeSourceFile();
++ }
++
++ $params = $this->getRequestParams($query, $headers, $uri);
++
++ try {
++ $this->response = new Response(
++ $this->getClient($address, $connKeepAlive)->request_data($params, false)
++ );
++ $this->message($successMessage);
++ } catch (\Exception $exception) {
++ if ($errorMessage === null) {
++ $this->error("Request failed", $exception);
++ } else {
++ $this->message($errorMessage);
++ }
++ $this->response = new Response();
++ }
++ if ($this->debug) {
++ $this->response->debugOutput();
++ }
++ return $this->response;
++ }
++
++ /**
++ * Execute multiple requests in parallel.
++ *
++ * @param array|int $requests
++ * @param string|null $address
++ * @param string|null $successMessage
++ * @param string|null $errorMessage
++ * @param bool $connKeepAlive
++ * @return Response[]
++ * @throws \Exception
++ */
++ public function multiRequest(
++ $requests,
++ string $address = null,
++ string $successMessage = null,
++ string $errorMessage = null,
++ bool $connKeepAlive = false
++ ) {
++ if ($this->hasError()) {
++ return new Response(null, true);
++ }
++
++ if (is_numeric($requests)) {
++ $requests = array_fill(0, $requests, []);
++ } elseif (!is_array($requests)) {
++ throw new \Exception('Requests can be either numeric or array');
++ }
++
++ try {
++ $connections = array_map(function ($requestData) use ($address, $connKeepAlive) {
++ $client = $this->getClient($address, $connKeepAlive);
++ $params = $this->getRequestParams(
++ $requestData['query'] ?? '',
++ $requestData['headers'] ?? [],
++ $requestData['uri'] ?? null
++ );
++ return [
++ 'client' => $client,
++ 'requestId' => $client->async_request($params, false),
++ ];
++ }, $requests);
++
++ $responses = array_map(function ($conn) {
++ $response = new Response($conn['client']->wait_for_response_data($conn['requestId']));
++ if ($this->debug) {
++ $response->debugOutput();
++ }
++ return $response;
++ }, $connections);
++ $this->message($successMessage);
++ return $responses;
++ } catch (\Exception $exception) {
++ if ($errorMessage === null) {
++ $this->error("Request failed", $exception);
++ } else {
++ $this->message($errorMessage);
++ }
++ }
++ }
++
++ /**
++ * Get client.
++ *
++ * @param string $address
++ * @param bool $keepAlive
++ * @return Client
++ */
++ private function getClient(string $address = null, $keepAlive = false)
++ {
++ $address = $address ? $this->processTemplate($address) : $this->getAddr();
++ if ($address[0] === '/') { // uds
++ $host = 'unix://' . $address;
++ $port = -1;
++ } elseif ($address[0] === '[') { // ipv6
++ $addressParts = explode(']:', $address);
++ $host = $addressParts[0];
++ if (isset($addressParts[1])) {
++ $host .= ']';
++ $port = $addressParts[1];
++ } else {
++ $port = $this->getPort();
++ }
++ } else { // ipv4
++ $addressParts = explode(':', $address);
++ $host = $addressParts[0];
++ $port = $addressParts[1] ?? $this->getPort();
++ }
++
++ if (!$keepAlive) {
++ return new Client($host, $port);
++ }
++
++ if (!isset($this->clients[$host][$port])) {
++ $client = new Client($host, $port);
++ $client->setKeepAlive(true);
++ $this->clients[$host][$port] = $client;
++ }
++
++ return $this->clients[$host][$port];
++ }
++
++ /**
++ * Display logs
++ *
++ * @param int $number
++ * @param string $ignore
++ */
++ public function displayLog(int $number = 1, string $ignore = 'systemd')
++ {
++ /* Read $number lines or until EOF */
++ while ($number > 0 || ($number < 0 && !feof($this->outDesc))) {
++ $a = fgets($this->outDesc);
++ if (empty($ignore) || !strpos($a, $ignore)) {
++ echo $a;
++ $number--;
++ }
++ }
++ }
++
++ /**
++ * Get a single log line
++ *
++ * @return null|string
++ */
++ private function getLogLine()
++ {
++ $read = [$this->outDesc];
++ $write = null;
++ $except = null;
++ if (stream_select($read, $write, $except, 2 )) {
++ return fgets($this->outDesc);
++ } else {
++ return null;
++ }
++ }
++
++ /**
++ * Get log lines
++ *
++ * @param int $number
++ * @param bool $skipBlank
++ * @param string $ignore
++ * @return array
++ */
++ public function getLogLines(int $number = 1, bool $skipBlank = false, string $ignore = 'systemd')
++ {
++ $lines = [];
++ /* Read $n lines or until EOF */
++ while ($number > 0 || ($number < 0 && !feof($this->outDesc))) {
++ $line = $this->getLogLine();
++ if (is_null($line)) {
++ break;
++ }
++ if ((empty($ignore) || !strpos($line, $ignore)) && (!$skipBlank || strlen(trim($line)) > 0)) {
++ $lines[] = $line;
++ $number--;
++ }
++ }
++
++ return $lines;
++ }
++
++ /**
++ * @return mixed|string
++ */
++ public function getLastLogLine()
++ {
++ $lines = $this->getLogLines();
++
++ return $lines[0] ?? '';
++ }
++
++ /**
++ * Send signal to the supplied PID or the server PID.
++ *
++ * @param string $signal
++ * @param int|null $pid
++ * @return string
++ */
++ public function signal($signal, int $pid = null)
++ {
++ if (is_null($pid)) {
++ $pid = $this->getPid();
++ }
++
++ return exec("kill -$signal $pid");
++ }
++
++ /**
++ * Terminate master process
++ */
++ public function terminate()
++ {
++ proc_terminate($this->masterProcess);
++ }
++
++ /**
++ * Close all open descriptors and process resources
++ *
++ * @param bool $terminate
++ */
++ public function close($terminate = false)
++ {
++ if ($terminate) {
++ $this->terminate();
++ }
++ fclose($this->outDesc);
++ proc_close($this->masterProcess);
++ }
++
++ /**
++ * Create a config file.
++ *
++ * @param string $extension
++ * @return string
++ * @throws \Exception
++ */
++ private function createConfig($extension = 'ini')
++ {
++ if (is_array($this->configTemplate)) {
++ $configTemplates = $this->configTemplate;
++ if (!isset($configTemplates['main'])) {
++ throw new \Exception('The config template array has to have main config');
++ }
++ $mainTemplate = $configTemplates['main'];
++ unset($configTemplates['main']);
++ if (!is_dir(self::CONF_DIR)) {
++ mkdir(self::CONF_DIR);
++ }
++ foreach ($configTemplates as $name => $configTemplate) {
++ $this->makeFile(
++ 'conf',
++ $this->processTemplate($configTemplate),
++ self::CONF_DIR,
++ $name
++ );
++ }
++ } else {
++ $mainTemplate = $this->configTemplate;
++ }
++
++ return $this->makeFile($extension, $this->processTemplate($mainTemplate));
++ }
++
++ /**
++ * Process template string.
++ *
++ * @param string $template
++ * @return string
++ */
++ private function processTemplate(string $template)
++ {
++ $vars = [
++ 'FILE:LOG:ACC' => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC],
++ 'FILE:LOG:ERR' => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR],
++ 'FILE:LOG:SLOW' => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW],
++ 'FILE:PID' => ['getAbsoluteFile', self::FILE_EXT_PID],
++ 'RFILE:LOG:ACC' => ['getRelativeFile', self::FILE_EXT_LOG_ACC],
++ 'RFILE:LOG:ERR' => ['getRelativeFile', self::FILE_EXT_LOG_ERR],
++ 'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW],
++ 'RFILE:PID' => ['getRelativeFile', self::FILE_EXT_PID],
++ 'ADDR:IPv4' => ['getAddr', 'ipv4'],
++ 'ADDR:IPv4:ANY' => ['getAddr', 'ipv4-any'],
++ 'ADDR:IPv6' => ['getAddr', 'ipv6'],
++ 'ADDR:IPv6:ANY' => ['getAddr', 'ipv6-any'],
++ 'ADDR:UDS' => ['getAddr', 'uds'],
++ 'PORT' => ['getPort', 'ip'],
++ 'INCLUDE:CONF' => self::CONF_DIR . '/*.conf',
++ ];
++ $aliases = [
++ 'ADDR' => 'ADDR:IPv4',
++ 'FILE:LOG' => 'FILE:LOG:ERR',
++ ];
++ foreach ($aliases as $aliasName => $aliasValue) {
++ $vars[$aliasName] = $vars[$aliasValue];
++ }
++
++ return preg_replace_callback(
++ '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/',
++ function ($matches) use ($vars) {
++ $varName = $matches[1];
++ if (!isset($vars[$varName])) {
++ $this->error("Invalid config variable $varName");
++ return 'INVALID';
++ }
++ $pool = $matches[2] ?? 'default';
++ $varValue = $vars[$varName];
++ if (is_string($varValue)) {
++ return $varValue;
++ }
++ $functionName = array_shift($varValue);
++ $varValue[] = $pool;
++ return call_user_func_array([$this, $functionName], $varValue);
++ },
++ $template
++ );
++ }
++
++ /**
++ * @param string $type
++ * @param string $pool
++ * @return string
++ */
++ public function getAddr(string $type = 'ipv4', $pool = 'default')
++ {
++ $port = $this->getPort($type, $pool, true);
++ if ($type === 'uds') {
++ return $this->getFile($port . '.sock');
++ }
++
++ return $this->getHost($type) . ':' . $port;
++ }
++
++ /**
++ * @param string $type
++ * @param string $pool
++ * @param bool $useAsId
++ * @return int
++ */
++ public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false)
++ {
++ if ($type === 'uds' && !$useAsId) {
++ return -1;
++ }
++
++ if (isset($this->ports['values'][$pool])) {
++ return $this->ports['values'][$pool];
++ }
++ $port = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1;
++ $this->ports['values'][$pool] = $this->ports['last'] = $port;
++
++ return $port;
++ }
++
++ /**
++ * @param string $type
++ * @return string
++ */
++ public function getHost(string $type = 'ipv4')
++ {
++ switch ($type) {
++ case 'ipv6-any':
++ return '[::]';
++ case 'ipv6':
++ return '[::1]';
++ case 'ipv4-any':
++ return '0.0.0.0';
++ default:
++ return '127.0.0.1';
++ }
++ }
++
++ /**
++ * Get listen address.
++ *
++ * @param string|null $template
++ * @return string
++ */
++ public function getListen($template = null)
++ {
++ return $template ? $this->processTemplate($template) : $this->getAddr();
++ }
++
++ /**
++ * Get PID.
++ *
++ * @return int
++ */
++ public function getPid()
++ {
++ $pidFile = $this->getFile('pid');
++ if (!is_file($pidFile)) {
++ return (int) $this->error("PID file has not been created");
++ }
++ $pidContent = file_get_contents($pidFile);
++ if (!is_numeric($pidContent)) {
++ return (int) $this->error("PID content '$pidContent' is not integer");
++ }
++
++ return (int) $pidContent;
++ }
++
++
++ /**
++ * @param string $extension
++ * @param string|null $dir
++ * @param string|null $name
++ * @return string
++ */
++ private function getFile(string $extension, $dir = null, $name = null)
++ {
++ $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension;
++
++ return is_null($dir) ? $fileName : $dir . '/' . $fileName;
++ }
++
++ /**
++ * @param string $extension
++ * @return string
++ */
++ private function getAbsoluteFile(string $extension)
++ {
++ return $this->getFile($extension);
++ }
++
++ /**
++ * @param string $extension
++ * @return string
++ */
++ private function getRelativeFile(string $extension)
++ {
++ $fileName = rtrim(basename($this->fileName), '.');
++
++ return $this->getFile($extension, null, $fileName);
++ }
++
++ /**
++ * @param string $extension
++ * @param string $prefix
++ * @return string
++ */
++ private function getPrefixedFile(string $extension, string $prefix = null)
++ {
++ $fileName = rtrim($this->fileName, '.');
++ if (!is_null($prefix)) {
++ $fileName = $prefix . '/' . basename($fileName);
++ }
++
++ return $this->getFile($extension, null, $fileName);
++ }
++
++ /**
++ * @param string $extension
++ * @param string $content
++ * @param string|null $dir
++ * @param string|null $name
++ * @return string
++ */
++ private function makeFile(string $extension, string $content = '', $dir = null, $name = null)
++ {
++ $filePath = $this->getFile($extension, $dir, $name);
++ file_put_contents($filePath, $content);
++
++ return $filePath;
++ }
++
++ /**
++ * @return string
++ */
++ public function makeSourceFile()
++ {
++ return $this->makeFile('src.php', $this->code);
++ }
++
++ /**
++ * @param string|null $msg
++ */
++ private function message($msg)
++ {
++ if ($msg !== null) {
++ echo "$msg\n";
++ }
++ }
++
++ /**
++ * @param string $msg
++ * @param \Exception|null $exception
++ */
++ private function error($msg, \Exception $exception = null)
++ {
++ $this->error = 'ERROR: ' . $msg;
++ if ($exception) {
++ $this->error .= '; EXCEPTION: ' . $exception->getMessage();
++ }
++ $this->error .= "\n";
++
++ echo $this->error;
++ }
++
++ /**
++ * @return bool
++ */
++ private function hasError()
++ {
++ return !is_null($this->error) || !is_null($this->logTool->getError());
++ }
++
++ /**
++ * Expect file with a supplied extension to exist.
++ *
++ * @param string $extension
++ * @param string $prefix
++ * @return bool
++ */
++ public function expectFile(string $extension, $prefix = null)
++ {
++ $filePath = $this->getPrefixedFile($extension, $prefix);
++ if (!file_exists($filePath)) {
++ return $this->error("The file $filePath does not exist");
++ }
++
++ return true;
++ }
++
++ /**
++ * Expect file with a supplied extension to not exist.
++ *
++ * @param string $extension
++ * @param string $prefix
++ * @return bool
++ */
++ public function expectNoFile(string $extension, $prefix = null)
++ {
++ $filePath = $this->getPrefixedFile($extension, $prefix);
++ if (file_exists($filePath)) {
++ return $this->error("The file $filePath exists");
++ }
++
++ return true;
++ }
++
++ /**
++ * Expect message to be written to FastCGI error stream.
++ *
++ * @param string $message
++ * @param int $limit
++ * @param int $repeat
++ */
++ public function expectFastCGIErrorMessage(
++ string $message,
++ int $limit = 1024,
++ int $repeat = 0
++ ) {
++ $this->logTool->setExpectedMessage($message, $limit, $repeat);
++ $this->logTool->checkTruncatedMessage($this->response->getErrorData());
++ }
++
++ /**
++ * Expect starting lines to be logged.
++ */
++ public function expectLogStartNotices()
++ {
++ $this->logTool->expectStartingLines($this->getLogLines(2));
++ }
++
++ /**
++ * Expect terminating lines to be logged.
++ */
++ public function expectLogTerminatingNotices()
++ {
++ $this->logTool->expectTerminatorLines($this->getLogLines(-1));
++ }
++
++ /**
++ * Expect log message that can span multiple lines.
++ *
++ * @param string $message
++ * @param int $limit
++ * @param int $repeat
++ * @param bool $decorated
++ * @param bool $wrapped
++ */
++ public function expectLogMessage(
++ string $message,
++ int $limit = 1024,
++ int $repeat = 0,
++ bool $decorated = true,
++ bool $wrapped = true
++ ) {
++ $this->logTool->setExpectedMessage($message, $limit, $repeat);
++ if ($wrapped) {
++ $logLines = $this->getLogLines(-1, true);
++ $this->logTool->checkWrappedMessage($logLines, true, $decorated);
++ } else {
++ $logLines = $this->getLogLines(1, true);
++ $this->logTool->checkTruncatedMessage($logLines[0] ?? '');
++ }
++ if ($this->debug) {
++ $this->message("-------------- LOG LINES: -------------");
++ var_dump($logLines);
++ $this->message("---------------------------------------\n");
++ }
++ }
++
++ /**
++ * Expect a single log line.
++ *
++ * @param string $message
++ * @return bool
++ */
++ public function expectLogLine(string $message)
++ {
++ $messageLen = strlen($message);
++ $limit = $messageLen > 1024 ? $messageLen + 16 : 1024;
++ $this->logTool->setExpectedMessage($message, $limit);
++ $logLines = $this->getLogLines(1, true);
++ if ($this->debug) {
++ $this->message("LOG LINE: " . ($logLines[0] ?? ''));
++ }
++
++ return $this->logTool->checkWrappedMessage($logLines, false);
++ }
++
++ /**
++ * Expect a log debug message.
++ *
++ * @param string $message
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectLogDebug(string $message, $pool = null)
++ {
++ return $this->logTool->expectDebug($this->getLastLogLine(), $message, $pool);
++ }
++
++ /**
++ * Expect a log notice.
++ *
++ * @param string $message
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectLogNotice(string $message, $pool = null)
++ {
++ return $this->logTool->expectNotice($this->getLastLogLine(), $message, $pool);
++ }
++
++ /**
++ * Expect a log warning.
++ *
++ * @param string $message
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectLogWarning(string $message, $pool = null)
++ {
++ return $this->logTool->expectWarning($this->getLastLogLine(), $message, $pool);
++ }
++
++ /**
++ * Expect a log error.
++ *
++ * @param string $message
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectLogError(string $message, $pool = null)
++ {
++ return $this->logTool->expectError($this->getLastLogLine(), $message, $pool);
++ }
++
++ /**
++ * Expect a log alert.
++ *
++ * @param string $message
++ * @param string|null $pool
++ * @return bool
++ */
++ public function expectLogAlert(string $message, $pool = null)
++ {
++ return $this->logTool->expectAlert($this->getLastLogLine(), $message, $pool);
++ }
++
++ /**
++ * Expect no log lines to be logged.
++ *
++ * @return bool
++ */
++ public function expectNoLogMessages()
++ {
++ $logLines = $this->getLogLines(-1, true);
++ if (!empty($logLines)) {
++ return $this->error(
++ "Expected no log lines but following lines logged:\n" . implode("\n", $logLines)
++ );
++ }
++
++ return true;
++ }
++
++ /**
++ * Print content of access log.
++ */
++ public function printAccessLog()
++ {
++ $accessLog = $this->getFile('acc.log');
++ if (is_file($accessLog)) {
++ print file_get_contents($accessLog);
++ }
++ }
++}
+--
+2.54.0
+
+From 3ef1cb1937145019ef836cba3a5736a37384c752 Mon Sep 17 00:00:00 2001
+From: Remi Collet <remi@remirepo.net>
+Date: Thu, 7 May 2026 09:01:35 +0200
+Subject: [PATCH 6/6] NEWS from 8.2.31
+
+(cherry picked from commit 7dff10e9a31d469fcd436e10b06f8b2bf2758a68)
+(cherry picked from commit 1cbf0c27044bd54fb77de8a6bf993a7ab53892a4)
+(cherry picked from commit 6b9f5d1673522bb3cf5d77889919084024565c7f)
+(cherry picked from commit 5be222339cd6d299aa9170e6fa9edd51a5c42f39)
+(cherry picked from commit 8884e113e8351693eb4b5f1c58485ad0e4508d3a)
+(cherry picked from commit 5cf6ff5fcde53a1a941fea374b483e9ff89a9f9f)
+---
+ NEWS | 18 ++++++++++++++++++
+ 1 file changed, 18 insertions(+)
+
+diff --git a/NEWS b/NEWS
+index 7096b787738..b5014af12e4 100644
+--- a/NEWS
++++ b/NEWS
+@@ -1,6 +1,24 @@
+ PHP NEWS
+ |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
+
++Backported from 8.2.31
++
++- FPM:
++ . Fixed GHSA-7qg2-v9fj-4mwv (XSS within status endpoint). (CVE-2026-6735)
++ (Jakub Zelenka)
++
++- SOAP:
++ . Fixed GHSA-85c2-q967-79q5 (Stale SOAP_GLOBAL(ref_map) pointer with Apache
++ Map). (CVE-2026-6722) (ilutov)
++ . Fixed GHSA-m33r-qmcv-p97q (Use-after-free after header parsing failure with
++ SOAP_PERSISTENCE_SESSION). (CVE-2026-7261) (ilutov)
++ . Fixed GHSA-hmxp-6pc4-f3vv (Broken Apache map value NULL check).
++ (CVE-2026-7262) (ilutov)
++
++- Standard:
++ . Fixed GHSA-96wq-48vp-hh57 (Signed integer overflow of char array offset).
++ (CVE-2026-7568) (TimWolla)
++
+ Backported from 8.1.31
+
+ - CLI:
+--
+2.54.0
+