N°7662 Fix the handling of PHP native deprecations (e.g. call strftime on PHP 8.1)

This commit is contained in:
Romain Quetiez
2024-08-23 16:35:52 +02:00
parent ffb61503dc
commit 37cd12fb21
2 changed files with 323 additions and 208 deletions

View File

@@ -1173,7 +1173,7 @@ class DeprecatedCallsLog extends LogAPI
/**
* This will catch a message for all E_DEPRECATED and E_USER_DEPRECATED errors.
* This handler is set in DeprecatedCallsLog::Enable
* This handler is set in {@see DeprecatedCallsLog::Enable}
*
* @param int $errno
* @param string $errstr
@@ -1193,52 +1193,22 @@ class DeprecatedCallsLog extends LogAPI
return false;
}
$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
if (isset($aStack[2]['function']) && ($aStack[2]['function'] == 'ForwardToTriggerError')) {
// Let the notice bubble up
return false;
}
if (false === static::IsLogLevelEnabledSafe(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_LIBMETHOD)) {
// returns true so that nothing is throwned !
// returns true so that nothing is thrown!
return true;
}
$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
$iStackDeprecatedMethodLevel = 2; // level 0 = current method, level 1 = @trigger_error, level 2 = method containing the `trigger_error` call (can be either 'trigger_deprecation' or the faulty method), level 3 = In some cases, method containing the 'trigger_deprecation' call
// In case current level is actually a 'trigger_deprecation' call, try to go one level further to get the real deprecated method
if (array_key_exists($iStackDeprecatedMethodLevel, $aStack) && ($aStack[$iStackDeprecatedMethodLevel]['function'] === 'trigger_deprecation') && array_key_exists($iStackDeprecatedMethodLevel + 1, $aStack)) {
$iStackDeprecatedMethodLevel++;
}
$sDeprecatedObject = $aStack[$iStackDeprecatedMethodLevel]['class'];
$sDeprecatedMethod = $aStack[$iStackDeprecatedMethodLevel]['function'];
if (($sDeprecatedObject === __CLASS__) && ($sDeprecatedMethod === 'Log')) {
// We are generating a trigger_error ourselves, we don't want to trace them !
return false;
}
$sCallerFile = $aStack[$iStackDeprecatedMethodLevel]['file'];
$sCallerLine = $aStack[$iStackDeprecatedMethodLevel]['line'];
$sMessage = "Call to {$sDeprecatedObject}::{$sDeprecatedMethod} in {$sCallerFile}#L{$sCallerLine}";
$iStackCallerMethodLevel = $iStackDeprecatedMethodLevel + 1; // level 3 = caller of the deprecated method
if (array_key_exists($iStackCallerMethodLevel, $aStack)) {
$sCallerObject = $aStack[$iStackCallerMethodLevel]['class'] ?? null;
$sCallerMethod = $aStack[$iStackCallerMethodLevel]['function'] ?? null;
$sMessage .= ' (';
if (!is_null($sCallerObject)) {
$sMessage .= "{$sCallerObject}::{$sCallerMethod}";
} else {
$sCallerMethodFile = $aStack[$iStackCallerMethodLevel]['file'];
$sCallerMethodLine = $aStack[$iStackCallerMethodLevel]['line'];
if (!is_null($sCallerMethod)) {
$sMessage .= "call to {$sCallerMethod}() in {$sCallerMethodFile}#L{$sCallerMethodLine}";
} else {
$sMessage .= "{$sCallerMethodFile}#L{$sCallerMethodLine}";
}
}
$sMessage .= ')';
}
if (!empty($errstr)) {
$sMessage .= ' : '.$errstr;
}
$aStack = static::StripCallStack($aStack);
$sMessage = "$errstr, called from ".static::SummarizeCallStack($aStack);
static::Warning($sMessage, self::ENUM_CHANNEL_PHP_LIBMETHOD);
static::ForwardToTriggerError($sMessage);
return true;
}
@@ -1264,6 +1234,8 @@ class DeprecatedCallsLog extends LogAPI
}
/**
* Call this helper at the beginning of a deprecated file (in its global scope)
*
* @since 3.0.1 3.1.0 N°4725 silently handles ConfigException
* @since 3.0.4 3.1.0 N°4725 remove forgotten throw PHPDoc annotation
*
@@ -1298,9 +1270,12 @@ class DeprecatedCallsLog extends LogAPI
}
static::Warning($sMessage, static::ENUM_CHANNEL_FILE);
static::ForwardToTriggerError($sMessage);
}
/**
* Call this helper when calling a deprecated extension method
*
* @param string $sImplementationClass Class implementing the deprecated API
* @param string $sDeprecatedApi Class name of the deprecated API
* @param string $sDeprecatedMethod Method name of the deprecated API
@@ -1327,9 +1302,12 @@ class DeprecatedCallsLog extends LogAPI
}
static::Warning($sMessage, self::ENUM_CHANNEL_PHP_API);
static::ForwardToTriggerError($sMessage);
}
/**
* Call this helper within deprecated methods
*
* @param string|null $sAdditionalMessage
*
* @link https://www.php.net/debug_backtrace
@@ -1347,52 +1325,24 @@ class DeprecatedCallsLog extends LogAPI
}
$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
$sMessage = self::GetMessageFromStack($aStack);
if (!is_null($sAdditionalMessage)) {
$sMessage .= ' : '.$sAdditionalMessage;
if (isset($aStack[1]['class'])) {
$sFunctionDesc = $aStack[1]['class'].$aStack[1]['type'].$aStack[1]['function'];
}
else {
$sFunctionDesc = $aStack[1]['function'];
}
$sMessage = "Function $sFunctionDesc() is deprecated";
if (!is_null($sAdditionalMessage)) {
$sMessage .= ': '.$sAdditionalMessage;
}
$sMessage .= '. Caller: '.self::SummarizeCallStack(array_slice($aStack, 1));
static::Warning($sMessage, self::ENUM_CHANNEL_PHP_METHOD);
}
/**
* @param array $aDebugBacktrace data from {@see debug_backtrace()}
*
* @return string message to print to the log
*/
private static function GetMessageFromStack(array $aDebugBacktrace): string
{
// level 0 = current method
// level 1 = deprecated method, containing the `NotifyDeprecatedPhpMethod` call
$sMessage = 'Call'.self::GetMessageForCurrentStackLevel($aDebugBacktrace[1], " to ");
// level 2 = caller of the deprecated method
if (array_key_exists(2, $aDebugBacktrace)) {
$sMessage .= ' (from ';
$sMessage .= self::GetMessageForCurrentStackLevel($aDebugBacktrace[2]);
$sMessage .= ')';
}
return $sMessage;
}
private static function GetMessageForCurrentStackLevel(array $aCurrentLevelDebugTrace, ?string $sPrefix = ""): string
{
$sMessage = "";
if (array_key_exists('class', $aCurrentLevelDebugTrace)) {
$sDeprecatedObject = $aCurrentLevelDebugTrace['class'];
$sDeprecatedMethod = $aCurrentLevelDebugTrace['function'] ?? "";
$sMessage = "{$sPrefix}{$sDeprecatedObject}::{$sDeprecatedMethod} in ";
}
if (array_key_exists('file', $aCurrentLevelDebugTrace)) {
$sCallerFile = $aCurrentLevelDebugTrace['file'];
$sCallerLine = $aCurrentLevelDebugTrace['line'] ?? "";
$sMessage .= "{$sCallerFile}#L{$sCallerLine}";
}
return $sMessage;
static::ForwardToTriggerError($sMessage);
}
/**
@@ -1422,14 +1372,11 @@ class DeprecatedCallsLog extends LogAPI
}
static::Warning($sMessage, self::ENUM_CHANNEL_PHP_ENDPOINT);
static::ForwardToTriggerError($sMessage);
}
public static function Log($sLevel, $sMessage, $sChannel = null, $aContext = array()): void
{
if (true === utils::IsDevelopmentEnvironment()) {
trigger_error($sMessage, E_USER_DEPRECATED);
}
try {
parent::Log($sLevel, $sMessage, $sChannel, $aContext);
}
@@ -1437,6 +1384,61 @@ class DeprecatedCallsLog extends LogAPI
// nothing much we can do... and we don't want to crash the caller !
}
}
/**
* Strips some elements from the top of the call stack to skip calls that are not relevant to report the deprecated call
* @param array $aCallStack Call stack as returned by {@see debug_backtrace()}
*/
protected static function StripCallStack($aCallStack): array
{
if (!isset($aCallStack[0]['line'])) {
$aCallStack = array_slice($aCallStack, 1);
}
if (isset($aCallStack[1]['function']) && $aCallStack[1]['function'] === 'trigger_deprecation') {
$aCallStack = array_slice($aCallStack, 1);
}
return $aCallStack;
}
protected static function SummarizeCallStack($aCallStack, $bRecurse = true)
{
if (count($aCallStack) == 0) {
return null;
}
$sFileLine = $aCallStack[0]['file'].'#'.$aCallStack[0]['line'];
$sSummary = $sFileLine;
// If possible and meaningful, add the class and method
if (isset($aCallStack[1]['class'])) {
$sSummary = $aCallStack[1]['class'].$aCallStack[1]['type'].$aCallStack[1]['function']." ($sFileLine)";
}
elseif (isset($aCallStack[1]['function'])) {
if (in_array($aCallStack[1]['function'], ['include', 'require', 'include_once', 'require_once'])) {
// No need to show the generic mechanism of inclusion
$bRecurse = false;
}
else {
$sSummary = $aCallStack[1]['function']." ($sFileLine)";
}
}
if ($bRecurse) {
$sUpperSummary = static::SummarizeCallStack(array_slice($aCallStack, 1), false);
if (!is_null($sUpperSummary)) {
$sSummary .= ', itself called from '.$sUpperSummary;
}
}
return $sSummary;
}
private static function ForwardToTriggerError(string $sMessage): void
{
if (true === utils::IsDevelopmentEnvironment()) {
trigger_error($sMessage, E_USER_DEPRECATED);
}
}
}

View File

@@ -13,143 +13,256 @@ use DeprecatedCallsLog;
class DeprecatedCallsLogTest extends ItopTestCase
{
/**
* We are testing for a undefined offset error. This was throwing a Notice, but starting with PHP 8.0 it was converted to a Warning ! Also the message was changed :(
*
* @link https://www.php.net/manual/en/migration80.incompatible.php check "A number of notices have been converted into warnings:"
* @dataProvider StripCallStackProvider
*/
private function SetUndefinedOffsetExceptionToExpect(): void
public function testStripCallStack($sInputStack, $sExpectedStack)
{
/** @noinspection ConstantCanBeUsedInspection Preferring the function call as it is easier to read and won't cost that much in this PHPUnit context */
if (version_compare(PHP_VERSION, '8.0', '>=')) {
$this->expectWarning();
$sUndefinedOffsetExceptionMessage = 'Undefined array key "tutu"';
} else {
$this->expectNotice();
$sUndefinedOffsetExceptionMessage = 'Undefined index: tutu';
}
$this->expectExceptionMessage($sUndefinedOffsetExceptionMessage);
$this->assertEquals(
$sExpectedStack,
$this->InvokeNonPublicStaticMethod(DeprecatedCallsLog::class, 'StripCallStack', [$sInputStack]),
'The top item of the call stack should be the first item meaningful to track deprecated calls, not the intermediate layers of the PHP engine'
);
}
public function testPhpNoticeWithoutDeprecatedCallsLog(): void
{
$this->SetUndefinedOffsetExceptionToExpect();
$aArray = [];
if ('toto' === $aArray['tutu']) {
//Do nothing, just raising a undefined offset warning
}
}
/**
* @runInSeparateProcess Necessary, due to the DeprecatedCallsLog being enabled (no mean to reset)
*
* The error handler set by DeprecatedCallsLog during startup was causing PHPUnit to miss PHP notices like "undefined offset"
*
* The error handler is now disabled when running PHPUnit
*
* @since 3.0.4 N°6274
* @covers DeprecatedCallsLog::DeprecatedNoticesErrorHandler
*/
public function testPhpNoticeWithDeprecatedCallsLog(): void
{
$this->RequireOnceItopFile('core/log.class.inc.php');
DeprecatedCallsLog::Enable(); // will set error handler
$this->SetUndefinedOffsetExceptionToExpect();
$aArray = [];
if ('toto' === $aArray['tutu']) {
//Do nothing, just raising a undefined offset warning
}
}
/**
* @dataProvider GetMessageFromStackProvider
*/
public function testGetMessageFromStack($aDebugBacktrace, $sExpectedMessage): void
{
$sActualMessage = $this->InvokeNonPublicStaticMethod(DeprecatedCallsLog::class, 'GetMessageFromStack', [$aDebugBacktrace]);
$this->assertEquals($sExpectedMessage, $sActualMessage);
}
public function GetMessageFromStackProvider()
public function StripCallStackProvider()
{
return [
'Call in a file outside of a function or class' => [
[
/* A deprecated PHP method is invoked from scratch.php, at line 25 (note: the name of the method is not present in the callstack) */
'Should preserve the handler when the notice is fired by PHP itself' => [
'in' => [
[
'file' => 'whateverfolder/scratch.php',
'line' => 25,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
],
'out' => [
[
'file' => 'whateverfolder/scratch.php',
'line' => 25,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
],
],
'Should skip the handler when the notice is fired by a call to trigger_error' => [
'in' => [
[
'file' => 'C:\Dev\wamp64\www\itop-32\sources\Application\WebPage\WebPage.php',
'line' => '866',
'function' => 'NotifyDeprecatedPhpMethod',
'class' => 'DeprecatedCallsLog',
'type' => '::',
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php',
'line' => '130',
'function' => 'add_linked_script',
'class' => 'Combodo\iTop\Application\WebPage\WebPage',
'type' => '->',
'file' => 'whateverfolder/scratch.php',
'line' => 25,
'function' => 'trigger_error',
],
],
'out' => [
[
'file' => 'whateverfolder/scratch.php',
'line' => 25,
'function' => 'trigger_error',
],
],
],
'Should skip two levels when the notice is fired by trigger_deprecation (Symfony helper)' => [
'in' => [
[
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'C:\Dev\wamp64\www\itop-32\pages\exec.php',
'line' => '102',
'args' => ['C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php'],
'file' => 'symfony/deprecation.php',
'line' => 12,
'function' => 'trigger_error',
],
[
'file' => 'symfony/service.php',
'line' => 25,
'function' => 'trigger_deprecation',
],
],
'out' => [
[
'file' => 'symfony/service.php',
'line' => 25,
'function' => 'trigger_deprecation',
],
],
],
];
}
/**
* @dataProvider SummarizeCallStackProvider
*/
public function testSummarizeCallStack($sInputStackStripped, $sExpectedSummary)
{
$this->assertEquals(
$sExpectedSummary,
$this->InvokeNonPublicStaticMethod(DeprecatedCallsLog::class, 'SummarizeCallStack', [$sInputStackStripped])
);
}
public function SummarizeCallStackProvider()
{
// All tests are based on a call stack issued from a deprecated PHP function
// Other cases are similar: what counts on the top level item is the file and line number of the deprecated call
return [
'From the main page (deprecated PHP function)' => [
'in:stripped call stack' => [
[
'file' => 'whateverfolder/scratch.php',
'line' => 25,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
],
'out' => 'whateverfolder/scratch.php#25',
],
'From an iTop method (deprecated PHP function)' => [
'in:stripped call stack' => [
[
'file' => 'whateverfolder/someclass.php',
'line' => 18,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'whateverfolder/index.php',
'line' => 25,
'function' => 'SomeMethod',
'class' => 'SomeClass',
'type' => '->',
],
],
'out' => 'SomeClass->SomeMethod (whateverfolder/someclass.php#18), itself called from whateverfolder/index.php#25',
],
'From an iTop static method (deprecated PHP function)' => [
'in:stripped call stack' => [
[
'file' => 'whateverfolder/someclass.php',
'line' => 18,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'whateverfolder/index.php',
'line' => 25,
'function' => 'SomeMethod',
'class' => 'SomeClass',
'type' => '::',
],
],
'out' => 'SomeClass::SomeMethod (whateverfolder/someclass.php#18), itself called from whateverfolder/index.php#25',
],
'From an iTop function (deprecated PHP function)' => [
'in:stripped call stack' => [
[
'file' => 'whateverfolder/someclass.php',
'line' => 18,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'whateverfolder/index.php',
'line' => 25,
'function' => 'SomeFunction',
],
],
'out' => 'SomeFunction (whateverfolder/someclass.php#18), itself called from whateverfolder/index.php#25',
],
'From a code snippet (deprecated PHP function)' => [
'in:stripped call stack' => [
[
'file' => 'itop-root/env-production/core/main.php',
'line' => 1290,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'itop-root/core/metamodel.class.php',
'line' => 6698,
'function' => 'require_once',
],
[
'file' => 'itop-root/env-production/autoload.php',
'line' => 6,
'function' => 'IncludeModule',
'class' => 'MetaModel',
'type' => '::',
],
[
'file' => 'itop-root/core/metamodel.class.php',
'line' => 6487,
'function' => 'require_once',
],
],
'Call to Combodo\iTop\Application\WebPage\WebPage::add_linked_script in C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php#L130 (from C:\Dev\wamp64\www\itop-32\pages\exec.php#L102)',
'out' => 'itop-root/env-production/core/main.php#1290'
],
'Call in a file function, outside of a class' => [
[
[
'file' => 'C:\\Dev\\wamp64\\www\\itop-32\\sources\\Application\\WebPage\\WebPage.php',
'line' => 866,
'function' => 'NotifyDeprecatedPhpMethod',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'C:\\Dev\\wamp64\\www\\itop-32\\extensions\\itop-object-copier\\copy.php',
'line' => 81,
'function' => 'add_linked_script',
'class' => 'Combodo\\iTop\\Application\\WebPage\\WebPage',
'type' => '->',
],
[
'file' => 'C:\\Dev\\wamp64\\www\\itop-32\\extensions\\itop-object-copier\\copy.php',
'line' => 123,
'function' => 'myFunction',
],
'From a persistent object method (deprecated PHP function)' => [
'in:stripped call stack' => [
[
'file' => 'itop-root/env-production/itop-tickets/model.itop-tickets.php',
'line' => 165,
'function' => 'DeprecatedNoticesErrorHandler',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'itop-root/core/dbobject.class.php',
'line' => 6575,
'function' => 'OnBeforeWriteTicket',
'class' => 'Ticket',
'type' => '->',
],
[
'file' => 'itop-root/application/cmdbabstract.class.inc.php',
'line' => 5933,
'function' => 'FireEvent',
'class' => 'DBObject',
'type' => '->',
],
[
'file' => 'itop-root/core/dbobject.class.php',
'line' => 3643,
'function' => 'FireEventBeforeWrite',
'class' => 'cmdbAbstractObject',
'type' => '->',
],
[
'file' => 'itop-root/application/cmdbabstract.class.inc.php',
'line' => 4593,
'function' => 'DBUpdate',
'class' => 'DBObject',
'type' => '->',
],
[
'file' => 'itop-root/sources/Controller/Base/Layout/ObjectController.php',
'line' => 649,
'function' => 'DBUpdate',
'class' => 'cmdbAbstractObject',
'type' => '->',
],
[
'file' => 'itop-root/pages/UI.php',
'line' => 720,
'function' => 'OperationApplyModify',
'class' => 'Combodo\\iTop\\Controller\\Base\\Layout\\ObjectController',
'type' => '->',
],
],
'Call to Combodo\iTop\Application\WebPage\WebPage::add_linked_script in C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php#L81 (from C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php#L123)',
],
'Call from a class method' => [
[
[
'file' => 'C:\\Dev\\wamp64\\www\\itop-32\\sources\\Application\\WebPage\\WebPage.php',
'line' => 866,
'function' => 'NotifyDeprecatedPhpMethod',
'class' => 'DeprecatedCallsLog',
'type' => '::',
],
[
'file' => 'C:\\Dev\\wamp64\\www\\itop-32\\extensions\\itop-object-copier\\copy.php',
'line' => 82,
'function' => 'add_linked_script',
'class' => 'Combodo\\iTop\\Application\\WebPage\\WebPage',
'type' => '->',
],
[
'file' => 'C:\\Dev\\wamp64\\www\\itop-32\\extensions\\itop-object-copier\\copy.php',
'line' => 125,
'function' => 'MyMethod',
'class' => 'MyClass',
'type' => '::',
],
],
'Call to Combodo\iTop\Application\WebPage\WebPage::add_linked_script in C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php#L82 (from MyClass::MyMethod in C:\Dev\wamp64\www\itop-32\extensions\itop-object-copier\copy.php#L125)',
'out' => 'Ticket->OnBeforeWriteTicket (itop-root/env-production/itop-tickets/model.itop-tickets.php#165), itself called from DBObject->FireEvent (itop-root/core/dbobject.class.php#6575)'
],
];
}