1
0
mirror of https://github.com/php/php-src.git synced 2026-04-26 17:38:14 +02:00
Files
archived-php-src/sapi/fpm/tests/tester.inc
T
Mark Gallagher 327bb21986 FPM: Implement access log filtering
Adds a setting "access.suppress_path" to php-fpm pool configurations
which causes successful GET requests to the specified URIs to be
excluded from the access log. This is to reduce noise caused by
automated health checks.

Requests with response codes outwith the successful range 200 - 299,
requests made with query parameters and requests which have a
Content-Length other than 0 will ignore this setting as a security
precaution.

Closes GH-8174, #80428 [1]

[1] https://bugs.php.net/bug.php?id=80428
2022-07-10 23:21:14 +01:00

1574 lines
41 KiB
PHP

<?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|array
*/
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;
/**
* @var string[]
*/
private $expectedAccessLogs;
/**
* @var bool
*/
private $expectSuppressableAccessLogEntries;
/**
* 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);
}
}
self::cleanConfigFiles();
}
/**
* Clean config files
*/
static public function cleanConfigFiles() {
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);
}
}
/**
* Skip if not running as root.
*/
static public function skipIfNotRoot()
{
if (getmyuid() != 0) {
die('skip not running as root');
}
}
/**
* Skip if running as root.
*/
static public function skipIfRoot()
{
if (getmyuid() == 0) {
die('skip running as root');
}
}
/**
* Skip if posix extension not loaded.
*/
static public function skipIfPosixNotLoaded()
{
if (!extension_loaded('posix')) {
die('skip posix extension not loaded');
}
}
/**
* 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() . ' -tt -y ' . $configFile . ' 2>&1';
exec($cmd, $output, $code);
if ($code) {
return preg_replace("/\[.+?\]/", "", $output[0]);
}
return null;
}
/**
* Start PHP-FPM master process
*
* @param array $extraArgs
* @return bool
* @throws \Exception
*/
public function start(array $extraArgs = [])
{
$configFile = $this->createConfig();
$desc = $this->outDesc ? [] : [1 => array('pipe', 'w'), 2 => array('redirect', 1)];
$cmd = [self::findExecutable(), '-F', '-O', '-y', $configFile];
if (getenv('TEST_FPM_RUN_AS_ROOT')) {
$cmd[] = '--allow-to-run-as-root';
}
$cmd = array_merge($cmd, $extraArgs);
$this->masterProcess = proc_open($cmd, $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', 'openmetrics']
) {
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 $scriptFilename
* @return array
*/
private function getRequestParams(
string $query = '',
array $headers = [],
string $uri = null,
string $scriptFilename = null,
?string $stdin = null
) {
if (is_null($scriptFilename)) {
$scriptFilename = $this->makeSourceFile();
}
if (is_null($uri)) {
$uri = "/" . basename($scriptFilename);
}
$params = array_merge(
[
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => is_null($stdin) ? 'GET' : 'POST',
'SCRIPT_FILENAME' => $scriptFilename ?: $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' => strlen($stdin ?? "") // Default to 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
* @param string|null $scriptFilename = null
* @return Response
*/
public function request(
string $query = '',
array $headers = [],
string $uri = null,
string $address = null,
string $successMessage = null,
string $errorMessage = null,
bool $connKeepAlive = false,
string $scriptFilename = null,
string $stdin = null
) {
if ($this->hasError()) {
return new Response(null, true);
}
$params = $this->getRequestParams($query, $headers, $uri, $scriptFilename, $stdin);
try {
$this->response = new Response(
$this->getClient($address, $connKeepAlive)->request_data($params, $stdin)
);
$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
* @param int $readTimeout
* @return Response[]
* @throws \Exception
*/
public function multiRequest(
$requests,
string $address = null,
string $successMessage = null,
string $errorMessage = null,
bool $connKeepAlive = false,
int $readTimeout = 0
) {
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) use ($readTimeout) {
$response = new Response($conn['client']->wait_for_response_data($conn['requestId'], $readTimeout));
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, $timeout=3)) {
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--;
}
}
if ($this->debug) {
foreach ($lines as $line) {
echo "LOG LINE: " . $line;
}
}
return $lines;
}
/**
* @return mixed|string
*/
public function getLastLogLine()
{
$lines = $this->getLogLines();
return $lines[0] ?? '';
}
/**
* @return string
*/
public function getUser()
{
return get_current_user();
}
/**
* @return string
*/
public function getGroup()
{
return get_current_group();
}
/**
* @return int
*/
public function getUid()
{
return getmyuid();
}
/**
* @return int
*/
public function getGid()
{
return getmygid();
}
/**
* Reload FPM by sending USR2 signal and optionally change config before that.
*
* @param string|array $configTemplate
* @return string
* @throws \Exception
*/
public function reload($configTemplate = null)
{
if (!is_null($configTemplate)) {
self::cleanConfigFiles();
$this->configTemplate = $configTemplate;
$this->createConfig();
}
return $this->signal('USR2');
}
/**
* 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'];
if (!is_dir(self::CONF_DIR)) {
mkdir(self::CONF_DIR);
}
foreach ($this->createPoolConfigs($configTemplates) as $name => $poolConfig) {
$this->makeFile(
'conf',
$this->processTemplate($poolConfig),
self::CONF_DIR,
$name
);
}
} else {
$mainTemplate = $this->configTemplate;
}
return $this->makeFile($extension, $this->processTemplate($mainTemplate));
}
/**
* Create pool config templates.
*
* @param array $configTemplates
* @return array
* @throws \Exception
*/
private function createPoolConfigs(array $configTemplates)
{
if (!isset($configTemplates['poolTemplate'])) {
unset($configTemplates['main']);
return $configTemplates;
}
$poolTemplate = $configTemplates['poolTemplate'];
$configs = [];
if (isset($configTemplates['count'])) {
$start = $configTemplates['start'] ?? 1;
for ($i = $start; $i < $start + $configTemplates['count']; $i++) {
$configs[$i] = str_replace('%index%', $i, $poolTemplate);
}
} elseif (isset($configTemplates['names'])) {
foreach($configTemplates['names'] as $name) {
$configs[$name] = str_replace('%name%', $name, $poolTemplate);
}
} else {
throw new \Exception('The config template requires count or names if poolTemplate set');
}
return $configs;
}
/**
* 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',
'USER' => ['getUser'],
'GROUP' => ['getGroup'],
'UID' => ['getUid'],
'GID' => ['getGid'],
];
$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') {
$address = $this->getFile($port . '.sock');
// Socket max path length is 108 on Linux and 104 on BSD,
// so we use the latter
if (strlen($address) <= 104) {
return $address;
}
return sys_get_temp_dir().'/'.
hash('crc32', dirname($address)).'-'.
basename($address);
}
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 reloading lines to be logged.
*
* @param int $socketCount
*/
public function expectLogReloadingNotices($socketCount = 1)
{
$this->logTool->expectReloadingLines($this->getLogLines($socketCount + 4));
}
/**
* 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, bool $is_stderr = true)
{
$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, true, $is_stderr);
}
/**
* Expect log entry.
*
* @param string $type The log type
* @param string $message The expected message
* @param string|null $pool The pool for pool prefixed log entry
* @param int $count The number of items
* @return bool
*/
private function expectLogEntry(string $type, string $message, $pool = null, $count = 1)
{
for ($i = 0; $i < $count; $i++) {
if (!$this->logTool->expectEntry($type, $this->getLastLogLine(), $message, $pool)) {
return false;
}
}
return true;
}
/**
* Expect a log debug message.
*
* @param string $message
* @param string|null $pool
* @param int $count
* @return bool
*/
public function expectLogDebug(string $message, $pool = null, $count = 1)
{
return $this->expectLogEntry(LogTool::DEBUG, $message, $pool, $count);
}
/**
* Expect a log notice.
*
* @param string $message
* @param string|null $pool
* @param int $count
* @return bool
*/
public function expectLogNotice(string $message, $pool = null, $count = 1)
{
return $this->expectLogEntry(LogTool::NOTICE, $message, $pool, $count);
}
/**
* Expect a log warning.
*
* @param string $message
* @param string|null $pool
* @param int $count
* @return bool
*/
public function expectLogWarning(string $message, $pool = null, $count = 1)
{
return $this->expectLogEntry(LogTool::WARNING, $message, $pool, $count);
}
/**
* Expect a log error.
*
* @param string $message
* @param string|null $pool
* @param int $count
* @return bool
*/
public function expectLogError(string $message, $pool = null, $count = 1)
{
return $this->expectLogEntry(LogTool::ERROR, $message, $pool, $count);
}
/**
* Expect a log alert.
*
* @param string $message
* @param string|null $pool
* @param int $count
* @return bool
*/
public function expectLogAlert(string $message, $pool = null, $count = 1)
{
return $this->expectLogEntry(LogTool::ALERT, $message, $pool, $count);
}
/**
* 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;
}
/**
* Expect log config options
*
* @param array $options
* @return bool
*/
public function expectLogConfigOptions(array $expectedOptions)
{
$configOptions = $this->getConfigOptions();
foreach ($expectedOptions as $expectedOption) {
if (array_search($expectedOption, $configOptions, true)) {
// Exact match found, no error
continue;
}
// Try to find similar key
$key = substr($expectedOption, 0, strpos($expectedOption, " = "));
$matches = array_filter($configOptions, fn($configOption) => substr($configOption, 0, strlen($key)) == $key);
if (empty($matches)) {
return $this->error("Expected config option: $expectedOption but {$key} is not set");
}
return $this->error(sprintf(
"Expected config option: %s but got: %s",
$expectedOption,
implode("; ", $matches)
));
}
return true;
}
/**
* Get set config options
*
* @return array
*/
private function getConfigOptions()
{
$options = [];
foreach ($this->getLogLines(-1) as $line) {
preg_match('/.+NOTICE:\s+(.+)\s=\s(.+)/', $line, $matches);
if ($matches) {
// normalize format for consistent checking
$options[] = sprintf("%s = %s", $matches[1], $matches[2]);
}
}
return $options;
}
/**
* Print content of access log.
*/
public function printAccessLog()
{
$accessLog = $this->getFile('acc.log');
if (is_file($accessLog)) {
print file_get_contents($accessLog);
}
}
/**
* Return content of access log.
*
* @return string|false
*/
public function getAccessLog()
{
$accessLog = $this->getFile('acc.log');
if (is_file($accessLog)) {
return file_get_contents($accessLog);
}
return false;
}
/**
* Expect a single access log line.
*
* @param string $LogLine
* @param bool $suppressable see expectSuppressableAccessLogEntries
*/
public function expectAccessLog(
string $logLine,
bool $suppressable = false
) {
if (!$suppressable || $this->expectSuppressableAccessLogEntries) {
$this->expectedAccessLogs[] = $logLine;
}
}
/**
* Checks that all access log entries previously listed as expected by
* calling "expectAccessLog" are in the access log.
*/
public function checkAccessLog()
{
if (isset($this->expectedAccessLogs)) {
$expectedAccessLog = implode("\n", $this->expectedAccessLogs) . "\n";
} else {
$this->error("Called checkAccessLog but did not previous call expectAccessLog");
}
if ($accessLog = $this->getAccessLog()) {
if ($expectedAccessLog !== $accessLog) {
$this->error(sprintf(
"Access log was not as expected.\nEXPECTED:\n%s\n\nACTUAL:\n%s",
$expectedAccessLog,
$accessLog
));
}
} else {
$this->error("Called checkAccessLog but access log does not exist");
}
}
/**
* Flags whether the access log check should expect to see suppressable
* log entries, i.e. the URL is not in access.suppress_path[] config
*
* @param bool
*/
public function expectSuppressableAccessLogEntries(bool $expectSuppressableAccessLogEntries)
{
$this->expectSuppressableAccessLogEntries = $expectSuppressableAccessLogEntries;
}
}