1441 lines
46 KiB
PHP
1441 lines
46 KiB
PHP
<?php
|
|
/**
|
|
* @brief Scanner Class
|
|
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a> (Matt F)
|
|
* @copyright (c) Invision Power Services, Inc.
|
|
* @license https://www.invisioncommunity.com/legal/standards/
|
|
* @package Invision Community
|
|
* @since Aug 2022
|
|
*/
|
|
|
|
namespace IPS\Application;
|
|
|
|
/* To prevent PHP errors (extending class does not exist) revealing path */
|
|
|
|
use Jose\Component\Console\P12CertificateLoaderCommand;
|
|
|
|
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
|
{
|
|
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* @brief Class for scanning apps and plugins to make sure they won't break everything (specifically on PHP8).
|
|
*/
|
|
class _Scanner extends \stdClass
|
|
{
|
|
/**
|
|
* @brief static cache for getMethods
|
|
*/
|
|
private static $methodInfoCache = array();
|
|
|
|
/**
|
|
* @brief static cache for getClassDetails
|
|
*/
|
|
private static $classDetailsCache = array();
|
|
|
|
/**
|
|
* @brief The default $options parameter for the scan methods
|
|
*/
|
|
private static $defaultScannerOptions = array(
|
|
"enabledOnly" => true,
|
|
"dirsToCheck" => null
|
|
);
|
|
|
|
/**
|
|
* Scan all the methods in hooks installed on a site to make sure they're declared in a php8 compatible way
|
|
*
|
|
* @param bool $getDetails Return the details of every hook and base class loaded? This will make the result SIGNIFICANTLY larger so leave false unless you need it.
|
|
* @param bool $shallowCheck Return a bool instead that indicates whether discrepancies were found. Potentially faster since it returns at the first discrepancy
|
|
* @param int $limit The upper limit of issues to return. Set to -1 for no limit; Note this makes no difference for shallowChecks
|
|
* @param int $hardLimit A hard limit for the parent class search and filesystem search... those are self-propagating and can go for quite a while. Pass -1 at your own risk to make them boundless
|
|
* @param array $options Options;
|
|
* @example
|
|
* array(
|
|
* "enabledOnly" => true, // Whether to only check enabled apps & plugins only. default is true
|
|
* "dirsToCheck" => [ "apps" => array(), "plugins" => array() ], // Filter for what apps & plugins to check. Empty/null checks all apps
|
|
* )
|
|
*
|
|
*
|
|
* @return bool|array[] By default, array of [$issues, $details], where both $issues and $details are arrays of issues and method details found respectively
|
|
*/
|
|
final public static function scanExtendedClasses( bool $getDetails=false, bool$shallowCheck=false, int $limit=500, int $hardLimit=5000, ?array $options=NULL )
|
|
{
|
|
$details = array();
|
|
$issues = array();
|
|
if ( empty( $options ) OR !\is_array( $options ) )
|
|
{
|
|
$options = static::$defaultScannerOptions;
|
|
}
|
|
|
|
$appDirsToCheck = isset( $options['dirsToCheck']['apps'] ) ? $options['dirsToCheck']['apps'] : ( isset( $options['dirsToCheck']['plugins'] ) ? array() : null );
|
|
$pluginDirsToCheck = isset( $options['dirsToCheck']['plugins'] ) ? $options['dirsToCheck']['plugins'] : ( isset( $options['dirsToCheck']['apps'] ) ? array() : null );
|
|
$enabledOnly = $options['enabledOnly'] ?? static::$defaultScannerOptions['enabledOnly'];
|
|
|
|
|
|
/* Iterate through all app & plugin directories */
|
|
$excludeDirs = [ 'hooks', 'setup', 'dev', 'sources\/vendor' ];
|
|
$classesToLoad = [];
|
|
$nodes = array();
|
|
$appAndPluginClasses = array();
|
|
foreach ( ( $enabledOnly ? \IPS\Application::enabledApplications() : \IPS\Application::applications() ) as $key => $app )
|
|
{
|
|
/* Skip it if we don't care */
|
|
if (
|
|
( !\is_array( $appDirsToCheck ) AND \in_array( $app->directory, \IPS\IPS::$ipsApps ) ) OR
|
|
( \is_array( $appDirsToCheck ) AND !\in_array( $app->directory, $appDirsToCheck ) )
|
|
)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
/* Create a tree like array of class inheritance; each node is indexed by its name and contains. Note this is not a node object tree but a relational tree
|
|
array(
|
|
"root" => "\IPS\<ipsApp|SystemClass>\...",
|
|
"parent" => "\IPS\<customApp|plugins>\...",
|
|
"method_details" => array(...)
|
|
)
|
|
*/
|
|
|
|
/* For each customization, get all the dirs recursively; find any php file that could break things */
|
|
$dir = ( ( \defined( 'IPS\CIC2' ) and \IPS\CIC2 ) ? \IPS\SITE_FILES_PATH : \IPS\ROOT_PATH ) . '/applications/' . $app->directory;
|
|
if ( !\is_dir( $dir ) )
|
|
{
|
|
static::log( "During the customization compatibility check, the app {$app->directory} did not have a valid directory and was skipped" );
|
|
continue;
|
|
}
|
|
|
|
if ( file_exists( $dir . '/Application.php' ) )
|
|
{
|
|
$contents = file_get_contents( $dir . '/Application.php' );
|
|
$methods = static::getMethods( $contents );
|
|
$classDetails = static::getClassDetails( $contents );
|
|
if ( $classDetails['globalClassName'] )
|
|
{
|
|
$classDetails['isApp'] = true;
|
|
$classDetails['appOrPlugin'] = $key;
|
|
$classDetails['filepath'] = $dir . '/Application.php';
|
|
$classDetails['isIps'] = false;
|
|
$classDetails['priority'] = $enabledOnly ? true : $app->enabled;
|
|
$nodes[$classDetails['globalClassName']] = $classDetails;
|
|
if ( $getDetails )
|
|
{
|
|
$details[$dir . '/Application.php'] = $classDetails['methods'];
|
|
}
|
|
|
|
if ( $classDetails['parentClass'] )
|
|
{
|
|
$appAndPluginClasses[$classDetails['globalClassName']] = 1;
|
|
$classesToLoad[$classDetails['parentClass']] = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
$directory = new \RecursiveDirectoryIterator($dir);
|
|
$iterator = new \RecursiveIteratorIterator($directory);
|
|
$skips = implode( '|', $excludeDirs );
|
|
$skipPattern = '/' . preg_quote( $dir, '/' ) . '\\/(?:' . $skips . ')/';
|
|
|
|
$i = 0;
|
|
foreach ( $iterator as $info )
|
|
{
|
|
if ( !( $info instanceof \SplFileInfo and $info->isFile() ) )
|
|
{
|
|
continue;
|
|
}
|
|
$pathname = $info->getPathname();
|
|
|
|
/* We did this already */
|
|
if ( $pathname === ($dir . '/Application.php') )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
/* Don't care about non-php files */
|
|
if ( \mb_strtolower( $info->getExtension() ) !== 'php' )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
|
|
if ( $hardLimit !== -1 and $i++ >= $hardLimit )
|
|
{
|
|
static::log( "Some PHP classes may not have been scanned for the app {$app->directory} since the filesystem search's hard limit of {$hardLimit} was reached." );
|
|
break;
|
|
}
|
|
|
|
/* Don't care about these directories */
|
|
if ( preg_match( $skipPattern, $pathname ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$contents = \file_get_contents( $pathname );
|
|
$classDetails = static::getClassDetails( $contents );
|
|
if ( $classDetails['globalClassName'] )
|
|
{
|
|
$classDetails['isApp'] = true;
|
|
$classDetails['appOrPlugin'] = $key;
|
|
$classDetails['filepath'] = $pathname;
|
|
$classDetails['isIps'] = false;
|
|
$classDetails['priority'] = $enabledOnly ? true : $app->enabled;
|
|
$nodes[$classDetails['globalClassName']] = $classDetails;
|
|
|
|
if ( $getDetails )
|
|
{
|
|
$details[$pathname] = $classDetails['methods'];
|
|
}
|
|
|
|
if ( $classDetails['parentClass'] )
|
|
{
|
|
$appAndPluginClasses[$classDetails['globalClassName']] = 1;
|
|
$classesToLoad[$classDetails['parentClass']] = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Now for the plugins */
|
|
foreach ( ( $enabledOnly ? \IPS\Plugin::enabledPlugins() : \IPS\Plugin::plugins() ) as $key => $plugin )
|
|
{
|
|
if ( \is_array( $pluginDirsToCheck ) AND !\in_array( $plugin->location, $pluginDirsToCheck ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$dir = ( ( \defined('\IPS\IN_DEV' ) AND \IPS\IN_DEV ) ? \IPS\ROOT_PATH : \IPS\SITE_FILES_PATH ) . '/plugins/' . $plugin->location;
|
|
|
|
if ( !( @is_dir( $dir ) ) )
|
|
{
|
|
$message = "Couldn't load the files for the plugin '{$plugin->location}'. '{$dir}' is not an accessible directory";
|
|
static::log( $message );
|
|
continue;
|
|
}
|
|
|
|
$dirs = [ $dir . '/tasks', $dir . '/widgets' ];
|
|
|
|
$pluginLimit = 200; /* We'll stop at 200 php files... more than that is suspicious */
|
|
$i = 0;
|
|
foreach ( $dirs as $dirToCheck )
|
|
{
|
|
if ( !( \file_exists( $dirToCheck ) AND \is_dir( $dirToCheck ) ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$directory = new \RecursiveDirectoryIterator( $dirToCheck );
|
|
foreach ( new \RecursiveIteratorIterator( $directory ) as $info )
|
|
{
|
|
if ( !( $info instanceof \SplFileInfo AND $info->isFile() ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$pathname = $info->getPathname();
|
|
if ( \strtolower( $info->getExtension() ) !== 'php' )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if ( $i >= $pluginLimit or ( $hardLimit !== -1 and $i >= $hardLimit ) )
|
|
{
|
|
$actualLimit = $hardLimit === -1 ? $pluginLimit : min( $pluginLimit, $hardLimit );
|
|
static::log( "Some PHP Files may not have been checked for the plugin {$plugin->location}. It contains at least {$actualLimit} PHP files in the widgets and tasks subdirectories." );
|
|
break;
|
|
}
|
|
$i++;
|
|
|
|
$contents = \file_get_contents( $pathname );
|
|
$classDetails = static::getClassDetails( $contents );
|
|
if ( $classDetails['globalClassName'] )
|
|
{
|
|
$classDetails['isApp'] = false;
|
|
$classDetails['appOrPlugin'] = $key;
|
|
$classDetails['filepath'] = $pathname;
|
|
$classDetails['isIps'] = false;
|
|
$classDetails['priority'] = $enabledOnly ? true : ( $plugin->enabled );
|
|
$nodes[$classDetails['globalClassName']] = $classDetails;
|
|
|
|
if ( $getDetails )
|
|
{
|
|
$details[$pathname] = $classDetails['methods'];
|
|
}
|
|
|
|
if ( $classDetails['parentClass'] )
|
|
{
|
|
$appAndPluginClasses[$classDetails['globalClassName']] = 1;
|
|
$classesToLoad[$classDetails['parentClass']] = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $limit <= 0 )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( !empty( $appAndPluginClasses ) )
|
|
{
|
|
/* Now load until we've traced back to oob IPS classes (ips apps and system) */
|
|
$i = 0;
|
|
$classesToLoad = array_keys( $classesToLoad );
|
|
while ( $classToLoad = array_shift( $classesToLoad ) )
|
|
{
|
|
if ( isset( $nodes[$classToLoad] ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
/* Determine the path */
|
|
$filename = '';
|
|
$appOrPlugin = null;
|
|
$isApp = false;
|
|
$isIps = false;
|
|
$bits = explode( '\\', ltrim( $classToLoad, '\\' ) );
|
|
|
|
/* If this doesn't belong to us, try a PSR-0 loader or ignore it */
|
|
$vendorName = array_shift( $bits );
|
|
if( $vendorName !== 'IPS' )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$baseClassName = array_pop( $bits );
|
|
|
|
/* Locate file */
|
|
$sourcesDirSet = FALSE;
|
|
foreach ( array_merge( $bits, array( $baseClassName ) ) as $i => $bit )
|
|
{
|
|
if( preg_match( "/^[a-z0-9]/", $bit ) )
|
|
{
|
|
if( $i === 0 )
|
|
{
|
|
if ( \in_array( $bit, \IPS\IPS::$ipsApps ) )
|
|
{
|
|
$isIps = true;
|
|
}
|
|
|
|
if ( \IPS\CIC2 AND !\in_array( $bit, \IPS\IPS::$ipsApps ) )
|
|
{
|
|
$filename .= \IPS\SITE_FILES_PATH . '/applications/'; // Applications are in the root on Cloud2
|
|
}
|
|
else
|
|
{
|
|
$filename .= \IPS\ROOT_PATH . '/applications/';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$sourcesDirSet = TRUE;
|
|
}
|
|
}
|
|
elseif ( $i === 3 and $bit === 'Upgrade' )
|
|
{
|
|
$bit = mb_strtolower( $bit );
|
|
}
|
|
elseif( $sourcesDirSet === FALSE )
|
|
{
|
|
if( $i === 0 )
|
|
{
|
|
$isIps = true;
|
|
$filename .= \IPS\ROOT_PATH . '/system/';
|
|
}
|
|
elseif ( $i === 1 and $bit === 'Application' )
|
|
{
|
|
// do nothing
|
|
}
|
|
else
|
|
{
|
|
$filename .= 'sources/';
|
|
}
|
|
$sourcesDirSet = TRUE;
|
|
}
|
|
|
|
$filename .= "{$bit}/";
|
|
}
|
|
|
|
/* Load it */
|
|
$filename = \substr( $filename, 0, -1 ) . '.php';
|
|
|
|
if ( !\file_exists( $filename ) )
|
|
{
|
|
$filename = \substr( $filename, 0, -4 ) . \substr( $filename, \strrpos( $filename, '/' ) );
|
|
if ( !file_exists( $filename ) )
|
|
{
|
|
static::log( "A class in an app extends the class {$classToLoad} for which no file {$filename} exists! The method scanner skipped comparing that file to any of its subclasses" );
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$contents = \file_get_contents( $filename );
|
|
$classDetails = static::getClassDetails( $contents );
|
|
if ( !$classDetails['globalClassName'] )
|
|
{
|
|
static::log( "A class in an app extends the class {$classToLoad} for which no valid class file can be found (checked {$filename}). The method scanner skipped comparing that file to any of its subclasses" );
|
|
continue;
|
|
}
|
|
|
|
$classDetails['isApp'] = $isApp;
|
|
$classDetails['appOrPlugin'] = $appOrPlugin;
|
|
$classDetails['filepath'] = $filename;
|
|
$classDetails['isIps'] = $isIps;
|
|
$classDetails['priority'] = false;
|
|
$nodes[$classDetails['globalClassName']] = $classDetails;
|
|
|
|
|
|
/* Add the parent to the classes to load if we still haven't reached our own IPS code; we're not going to trudge through IPS code */
|
|
if ( !$isIps AND $classDetails['parentClass'] )
|
|
{
|
|
$classesToLoad[] = $classDetails['parentClass'];
|
|
}
|
|
|
|
if ( $hardLimit !== -1 AND $i++ >= $hardLimit )
|
|
{
|
|
static::log( "The Extended Class Compatibility scanner ran but may have been inaccurate. Not all parent classes could be loaded since the hard limit of {$hardLimit} was reached." );
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* Now we have the inheritance tree, compare extended classes from the app classes */
|
|
foreach ( $appAndPluginClasses as $appOrPluginClass => $var )
|
|
{
|
|
$classDetails = $nodes[$appOrPluginClass];
|
|
$appOrPlugin = '';
|
|
if ( @$classDetails['appOrPlugin'] )
|
|
{
|
|
$appOrPlugin = ( @$classDetails['isApp'] ) ? 'App - ' : 'Plugin - ';
|
|
$appOrPlugin .= $classDetails['appOrPlugin'];
|
|
}
|
|
else
|
|
{
|
|
static::log( "Something went wrong when evaluating the class {$classDetails['globalClassName']}." );
|
|
continue;
|
|
}
|
|
if ( $parentClass = @$classDetails['parentClass'] )
|
|
{
|
|
if ( $parentClassDetails = @$nodes[$parentClass] )
|
|
{
|
|
$priority = $classDetails['priority'] ?? 1;
|
|
$methodIssues = static::compareMethodsForIssues(
|
|
$parentClassDetails['methods'],
|
|
$classDetails['methods'],
|
|
$parentClassDetails['filepath'],
|
|
$classDetails['filepath'],
|
|
$parentClassDetails['globalClassName'],
|
|
$classDetails['globalClassName'],
|
|
$shallowCheck,
|
|
$limit,
|
|
$priority
|
|
);
|
|
|
|
if ( !empty( $methodIssues ) )
|
|
{
|
|
|
|
if ( $shallowCheck )
|
|
{
|
|
if ( $priority or !( $options['shallowCheckPriorityOnly'] ?? 1 ) )
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ( $limit !== -1 and \is_countable( $methodIssues ) )
|
|
{
|
|
$limit -= \count( $methodIssues );
|
|
}
|
|
|
|
$issues[ $appOrPlugin ] = $issues[$appOrPlugin] ?? [];
|
|
$issues[ $appOrPlugin ][ $classDetails['filepath'] ] = is_array( $methodIssues ) ? array_merge( $issues[ $appOrPlugin ][ $classDetails['filepath'] ] ?? [], $methodIssues ): $issues[ $appOrPlugin ][ $classDetails['filepath'] ];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
static::log( "The class {$appOrPluginClass} extends {$parentClass}, but the parent class couldn't be loaded." );
|
|
}
|
|
}
|
|
else
|
|
{
|
|
static::log( "The class {$appOrPluginClass} was queued to be compared to its parent class, but no parent class was found" );
|
|
}
|
|
}
|
|
}
|
|
|
|
/* return issues */
|
|
if ( $shallowCheck )
|
|
{
|
|
return empty( $issues ) ? null : false; // if we got an issue but haven't returned with shallow check, none of the issues are priority
|
|
}
|
|
|
|
return $getDetails ? [ $issues, $details ] : [ $issues ];
|
|
}
|
|
|
|
|
|
/**
|
|
* Compare 2 sets of methods from a subclass and its parent, and returns all the issues it could find with how the methods are declared
|
|
*
|
|
* @see PHP8 uses the Liskov Substitution Principle (LSP) to determine method compatibility https://www.php.net/manual/en/language.oop5.basic.php#language.oop.lsp
|
|
*
|
|
* @param array[] $baseMethods The Methods from the parent/base class
|
|
* @param array[] $subclassMethods The Methods from the child class
|
|
* @param string $baseFile The path to the base/parent file; will trim out \IPS\ROOT_PATH and SITE_FILES_PATH if it starts with it
|
|
* @param string $subclassFile The path to the subclass file; will trim out \IPS\ROOT_PATH and SITE_FILES_PATH if it starts with it
|
|
* @param string $baseClassName The name of the base class being evaluated; necessary to render all the context details about the issue
|
|
* @param string $subclassName The name of the extending subclass being evaluated; necessary to render all the context details about the issue
|
|
* @param bool $shallowCheck Perform a lightweight shallow check? If this is true, the method returns a bool, and will return TRUE as soon as it comes across the first issue
|
|
* @param int $limit The limit of the issues; set -1 to run without a limit. This is important when scanning all files across entire scaled communities
|
|
* @param bool $isHook Is the subclass a hook? If so, will set the 'class' property of the output to the base class instead of the subclass
|
|
* @param bool $isPriority Are these issues going to be considered priority? If so that's added here to prevent memory issues adding manually later
|
|
*
|
|
* @return bool|array
|
|
*/
|
|
final public static function compareMethodsForIssues( $baseMethods, $subclassMethods, $baseFile, $subclassFile, $baseClassName, $subclassName, $shallowCheck=false, $limit=500, $isHook=false, $isPriority=false )
|
|
{
|
|
/* For the file paths, remove the root or site files path for security and readability; anyone who can do something about these files knows what those are */
|
|
$searches = [\IPS\ROOT_PATH];
|
|
if ( \defined( '\IPS\SITE_FILES_PATH' ) )
|
|
{
|
|
$searches[] = \IPS\SITE_FILES_PATH;
|
|
}
|
|
foreach ( ['baseFile', 'subclassFile'] as $arg )
|
|
{
|
|
foreach ( $searches as $const )
|
|
{
|
|
if ( \mb_substr( $$arg, 0, \mb_strlen( $const ) ) === $const )
|
|
{
|
|
$$arg = \mb_substr( $$arg, \mb_strlen( $const ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
/* This is what we're looking for */
|
|
$issues = array();
|
|
$methodFieldMap = array(
|
|
'security' => 'method_issue_security',
|
|
'static' => 'method_issue_static',
|
|
'returnType' => 'method_issue_return_type'
|
|
);
|
|
|
|
$parameterFieldMap = array(
|
|
"type" => "method_issue_parameter_type",
|
|
"nullable" => "method_issue_nullable",
|
|
"passedByReference" => 'method_issue_reference',
|
|
"packed" => "method_issue_packed"
|
|
);
|
|
|
|
/* Only compare overlapping methods */
|
|
$overloaded = array_intersect( array_keys( $baseMethods ), array_keys( $subclassMethods ) );
|
|
$count = 0;
|
|
foreach ( $overloaded as $method )
|
|
{
|
|
$baseMethod = $baseMethods[$method];
|
|
$subclassMethod = $subclassMethods[$method];
|
|
|
|
/* According to the Liskov Substitution Principle, constructors and private methods are exempt */
|
|
if ( $method === '__construct' OR ( $baseMethod['security'] === 'private' and $subclassMethod['security'] === 'private' ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach ( $methodFieldMap as $field => $reason )
|
|
{
|
|
/* A protected base class method can be made public in a subclass */
|
|
if ( $field === 'security' and ( $baseMethod['security'] === 'protected' and $subclassMethod['security'] === 'public' ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
/* A subclass can add a return type hint if the base class does not have one */
|
|
if ( $field === 'returnType' and ( ! $baseMethod[$field] and $subclassMethod[$field] ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if ( $baseMethod[$field] !== $subclassMethod[$field] )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
return true;
|
|
}
|
|
|
|
$issues[] = array(
|
|
"method" => $method,
|
|
"reason" => $reason,
|
|
"parameter" => null,
|
|
"subclassFile" => $subclassFile,
|
|
"baseFile" => $baseFile,
|
|
"baseClass" => $baseClassName,
|
|
"subclassMethod" => $subclassMethod,
|
|
"subclassName" => $subclassName,
|
|
"baseMethod" => $baseMethod,
|
|
"class" => $isHook ? $baseClassName : $subclassName,
|
|
'priority' => $isPriority
|
|
);
|
|
|
|
if ( $limit !== -1 AND ++$count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $baseMethod['final'] )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
return true;
|
|
}
|
|
|
|
$issues[] = array(
|
|
"method" => $method,
|
|
"reason" => "method_issue_final",
|
|
"parameter" => null,
|
|
"subclassFile" => $subclassFile,
|
|
"baseFile" => $baseFile,
|
|
"baseClass" => $baseClassName,
|
|
"subclassMethod" => $subclassMethod,
|
|
"subclassName" => $subclassName,
|
|
"baseMethod" => $baseMethod,
|
|
"class" => $isHook ? $baseClassName : $subclassName,
|
|
'priority' => $isPriority
|
|
);
|
|
|
|
if ( $limit !== -1 AND ++$count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* What are our parameters */
|
|
if ( \count( $baseMethod['parameters'] ) or \count( $subclassMethod['parameters'] ) )
|
|
{
|
|
/* The subclass param names can be different from the base class, but must be the same type, nullable, etc otherwise */
|
|
$pass = true;
|
|
$baseParamsByIndex = array_values( $baseMethod['parameters'] );
|
|
$subclassParamsByIndex = array_values( $subclassMethod['parameters'] );
|
|
|
|
/* The extended class has fewer params than the base class */
|
|
if ( \count( $baseParamsByIndex ) > 0 and ( \count( $baseParamsByIndex ) > \count( $subclassParamsByIndex ) ) )
|
|
{
|
|
$pass = false;
|
|
}
|
|
else if ( \count( $baseParamsByIndex ) > 0 )
|
|
{
|
|
foreach ( range( 0, \count( $baseParamsByIndex ) - 1 ) as $i )
|
|
{
|
|
foreach( $baseParamsByIndex[$i] as $key => $value )
|
|
{
|
|
if ( $key !== 'name' )
|
|
{
|
|
if ( $baseParamsByIndex[$i][ $key ] != $subclassParamsByIndex[$i][ $key ] )
|
|
{
|
|
$pass = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $pass === false )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
return true;
|
|
}
|
|
|
|
$issues[] = array(
|
|
"method" => $method,
|
|
"reason" => "method_issue_parameters",
|
|
"parameter" => null,
|
|
"subclassFile" => $subclassFile,
|
|
"baseFile" => $baseFile,
|
|
"baseClass" => $baseClassName,
|
|
"subclassMethod" => $subclassMethod,
|
|
"subclassName" => $subclassName,
|
|
"baseMethod" => $baseMethod,
|
|
"class" => $isHook ? $baseClassName : $subclassName,
|
|
'priority' => $isPriority
|
|
);
|
|
|
|
if ( $limit !== -1 AND ++$count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( $limit !== -1 AND $count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach ( array_intersect( array_keys( $baseMethod['parameters'] ), array_keys( $subclassMethod['parameters'] ) ) as $param )
|
|
{
|
|
foreach ( $parameterFieldMap as $field => $reason )
|
|
{
|
|
if ( $baseMethod['parameters'][$param][$field] !== $subclassMethod['parameters'][$param][$field] )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
return true;
|
|
}
|
|
|
|
$issues[] = array(
|
|
"method" => $method,
|
|
"reason" => $reason,
|
|
"parameter" => $param,
|
|
"subclassFile" => $subclassFile,
|
|
"baseFile" => $baseFile,
|
|
"baseClass" => $baseClassName,
|
|
"subclassMethod" => $subclassMethod,
|
|
"subclassName" => $subclassName,
|
|
"baseMethod" => $baseMethod,
|
|
"class" => $isHook ? $baseClassName : $subclassName,
|
|
'priority' => $isPriority
|
|
);
|
|
|
|
if ( $limit !== -1 AND ++$count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $limit !== -1 AND $count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $shallowCheck )
|
|
{
|
|
return !empty( $issues );
|
|
}
|
|
|
|
return $issues;
|
|
}
|
|
|
|
/**
|
|
* @brief Enable logging for this scan
|
|
*/
|
|
protected static bool $logging = TRUE;
|
|
|
|
/**
|
|
* Scan a class and subclass installed on a site to make sure any methods are overloaded in a php8 compatible way
|
|
*
|
|
* @param bool $getDetails Return the details of every class loaded? This will make the result SIGNIFICANTLY larger memory-wise so leave false unless you need it.
|
|
* @param bool $shallowCheck Return a bool instead that indicates whether discrepancies were found. Fastest since it returns at the first discrepancy and lightweight since it doesn't store all that data in memory
|
|
* @param int $limit The upper limit of issues to return. Set to -1 for no limit; Note this makes no difference for shallowChecks
|
|
* @param array $options Options;
|
|
* @example
|
|
* array(
|
|
* "enabledOnly" => true, // Whether to only check enabled apps & plugins only. default is true
|
|
* "dirsToCheck" => [ "apps" => array(), "plugins" => array() ], // Filter for what apps & plugins to check. Empty/null checks all apps
|
|
* "logging" => true // Whether to disable logging for this scan, default is true
|
|
* )
|
|
*
|
|
* @return bool|null|array[] By default, array of [$issues, $details], where both $issues and $details are arrays of issues and method details found respectively
|
|
*/
|
|
final public static function scanCustomizationIssues( $getDetails=false, $shallowCheck=false, $limit=500, $options=NULL )
|
|
{
|
|
if( isset( $options['logging'] ) )
|
|
{
|
|
static::$logging = $options['logging'];
|
|
}
|
|
|
|
$issues = array();
|
|
$details = array();
|
|
|
|
/* Get issues with hooks */
|
|
$hookIssues = static::scanHooks( $getDetails, $shallowCheck, $limit, $options );
|
|
if ( !empty( $hookIssues ) OR ( $shallowCheck AND $hookIssues !== null ) )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
return $hookIssues;
|
|
}
|
|
|
|
foreach ( $hookIssues[0] as $appOrPlugin => $classes )
|
|
{
|
|
$issues[$appOrPlugin] = $issues[$appOrPlugin] ?? [];
|
|
foreach ( $classes as $className => $classIssues )
|
|
{
|
|
$issues[$appOrPlugin][$className] = array_merge( $issues[$appOrPlugin][$className] ?? [], $classIssues );
|
|
}
|
|
}
|
|
|
|
if ( $getDetails )
|
|
{
|
|
foreach ( $hookIssues[1] as $name => $methodData )
|
|
{
|
|
$details[$name] = $details[$name] ?? $methodData;
|
|
}
|
|
}
|
|
|
|
if ( $limit !== -1 )
|
|
{
|
|
$limit -= \count( $hookIssues );
|
|
}
|
|
}
|
|
|
|
/* Get issues with real classes inheriting IPS code */
|
|
$extendingIssues = static::scanExtendedClasses( $getDetails, $shallowCheck, $limit, 5000, $options );
|
|
if ( $extendingIssues OR ( $shallowCheck and $extendingIssues !== null ) )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
return $extendingIssues;
|
|
}
|
|
|
|
foreach ( $extendingIssues[0] as $appOrPlugin => $classes )
|
|
{
|
|
$issues[$appOrPlugin] = $issues[$appOrPlugin] ?? [];
|
|
foreach ( $classes as $className => $classIssues )
|
|
{
|
|
$issues[$appOrPlugin][$className] = array_merge( $issues[$appOrPlugin][$className] ?? [], $classIssues );
|
|
}
|
|
}
|
|
|
|
if ( $getDetails )
|
|
{
|
|
foreach ( $extendingIssues[1] as $name => $methodData )
|
|
{
|
|
$details[$name] = $details[$name] ?? $methodData;
|
|
}
|
|
}
|
|
|
|
if ( $limit !== -1 )
|
|
{
|
|
$limit -= \count( $hookIssues );
|
|
}
|
|
}
|
|
|
|
if ( $shallowCheck )
|
|
{
|
|
if ( !empty( $issues ) )
|
|
{
|
|
foreach( $issues as $appOrPlugin => $classIssues )
|
|
{
|
|
foreach( $classIssues as $className => $issues )
|
|
{
|
|
foreach ( $issues as $issue )
|
|
{
|
|
if ( $issue['priority'] ?? 0 )
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return $getDetails ? [ $issues, $details ] : [ $issues ];
|
|
}
|
|
|
|
/**
|
|
* Scan all the methods in hooks installed on a site to make sure they're declared in a php8 compatible way
|
|
*
|
|
* @param bool $getDetails Return the details of every hook and base class loaded? This will make the result SIGNIFICANTLY larger so leave false unless you need it.
|
|
* @param bool $shallowCheck Return a bool instead that indicates whether discrepancies were found. Potentially faster since it returns at the first discrepancy
|
|
* @param int $limit The upper limit of issues to return. Set to -1 for no limit; Note this makes no difference for shallowChecks
|
|
* @param array $options Options;
|
|
* @example
|
|
* array(
|
|
* "enabledOnly" => true, // Whether to only check enabled apps & plugins only. default is true
|
|
* "dirsToCheck" => [ "apps" => array(), "plugins" => array() ], // Filter for what apps & plugins to check. Empty/null checks all apps
|
|
* )
|
|
*
|
|
* @return bool|null|array[] By default, array of [$issues, $details], where both $issues and $details are arrays of issues and method details found respectively
|
|
*/
|
|
final public static function scanHooks( $getDetails=false, $shallowCheck=false, $limit=500, $options=NULL )
|
|
{
|
|
$details = array();
|
|
$issues = array();
|
|
if ( empty( $options ) OR !\is_array( $options ) )
|
|
{
|
|
$options = static::$defaultScannerOptions;
|
|
}
|
|
|
|
$appDirsToCheck = isset( $options['dirsToCheck']['apps'] ) ? $options['dirsToCheck']['apps'] : ( isset( $options['dirsToCheck']['plugins'] ) ? array() : null );
|
|
$pluginDirsToCheck = isset( $options['dirsToCheck']['plugins'] ) ? $options['dirsToCheck']['plugins'] : ( isset( $options['dirsToCheck']['apps'] ) ? array() : null );
|
|
$enabledOnly = $options['enabledOnly'] ?? static::$defaultScannerOptions['enabledOnly'];
|
|
|
|
/* ITERATE through our hooks */
|
|
if ( $enabledOnly )
|
|
{
|
|
if ( !empty( \IPS\IPS::$hooks ) OR file_exists( \IPS\SITE_FILES_PATH . "/plugins/hooks.php" ) )
|
|
{
|
|
if ( empty( \IPS\IPS::$hooks ) )
|
|
{
|
|
\IPS\IPS::$hooks = require( \IPS\SITE_FILES_PATH . '/plugins/hooks.php' );
|
|
}
|
|
}
|
|
|
|
if ( empty( \IPS\IPS::$hooks ) )
|
|
{
|
|
return $shallowCheck ? false : ( $getDetails ? array( [], [] ) : array( [] ) );
|
|
}
|
|
$hooksToCheck = \IPS\IPS::$hooks;
|
|
}
|
|
else
|
|
{
|
|
$hooksToCheck = array();
|
|
$where = array(
|
|
[ 'type=?', 'C' ]
|
|
);
|
|
|
|
if ( $appDirsToCheck !== null OR $pluginDirsToCheck !== null )
|
|
{
|
|
$appAndPluginClauses = [];
|
|
if ( !empty( $appDirsToCheck ) )
|
|
{
|
|
$appAndPluginClauses[] = \IPS\Db::i()->in( 'app', $appDirsToCheck );
|
|
}
|
|
|
|
if ( !empty( $pluginDirsToCheck ) )
|
|
{
|
|
$appAndPluginClauses[] = \IPS\Db::i()->in( 'plugin', $pluginDirsToCheck );
|
|
}
|
|
|
|
if ( !empty( $appAndPluginClauses ) )
|
|
{
|
|
$where[] = [ '( ' . implode( ' OR ', $appAndPluginClauses ) . ' )' ];
|
|
}
|
|
}
|
|
$select = \IPS\Db::i()->select( 'core_hooks.*, core_plugins.plugin_location, core_plugins.plugin_enabled, core_applications.app_enabled', 'core_hooks', $where )
|
|
->join( 'core_plugins', 'core_plugins.plugin_id=core_hooks.plugin' )
|
|
->join( 'core_applications', 'core_applications.app_directory=core_hooks.app' );
|
|
|
|
foreach ( $select as $row )
|
|
{
|
|
if ( empty( $row['app'] ?? $row['plugin_location'] ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$fileSource = ( $row['app'] === null ? 'plugins/' : 'applications/' ) . ( $row['app'] ?? $row['plugin_location'] ) . '/hooks/' . $row['filename'] . '.php';
|
|
$enabled = \boolval( !empty( $row['app'] ) ? ( $row['app_enabled'] ?? true ) : ( $row['plugin_enabled'] ?? true ) );
|
|
if ( !file_exists( \IPS\SITE_FILES_PATH . '/' . $fileSource ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$hooksToCheck[$row['class']][$row['id']] = array(
|
|
'file' => $fileSource,
|
|
'class' => ( $row['app'] === null ) ? "hook{$row['id']}" : "{$row['app']}_hook_{$row['filename']}",
|
|
'isPriority' => $enabled
|
|
);
|
|
}
|
|
}
|
|
|
|
$count = 0;
|
|
|
|
/* Teeny tiny optimization, but we'll save these for each app rather than compute on each and every hook */
|
|
$hookBases = array( 'app' => array(), 'plugin' => array() );
|
|
$shouldSkip = array( 'app' => array(), 'plugin' => array() );
|
|
|
|
foreach ( $hooksToCheck as $baseClass => $hooks )
|
|
{
|
|
if ( \mb_substr( trim( $baseClass, '\\' ), 0, 9 ) === 'IPS\\Theme' )
|
|
{
|
|
continue;
|
|
}
|
|
$baseFile = null;
|
|
|
|
/* Determine the path */
|
|
$bits = explode( '\\', ltrim( $baseClass, '\\' ) );
|
|
|
|
/* If this doesn't belong to us, try a PSR-0 loader or ignore it */
|
|
$vendorName = array_shift( $bits );
|
|
if( $vendorName !== 'IPS' )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
/* Work out what namespace we're in */
|
|
$baseClassName = array_pop( $bits );
|
|
$baseClassNamespace = '\\IPS\\' . implode( '\\', $bits );
|
|
|
|
/* Locate file */
|
|
$path = '';
|
|
$sourcesDirSet = FALSE;
|
|
$baseFileLocation = \IPS\SITE_FILES_PATH;
|
|
foreach ( array_merge( $bits, array( $baseClassName ) ) as $i => $bit )
|
|
{
|
|
if( preg_match( "/^[a-z0-9]/", $bit ) )
|
|
{
|
|
if( $i === 0 )
|
|
{
|
|
if ( \IPS\CIC2 AND !\in_array( $bit, \IPS\IPS::$ipsApps ) )
|
|
{
|
|
$path .= \IPS\SITE_FILES_PATH . '/applications/'; // Applications are in the root on Cloud2
|
|
}
|
|
else
|
|
{
|
|
$path .= \IPS\ROOT_PATH . '/applications/';
|
|
$baseFileLocation = \IPS\ROOT_PATH;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$sourcesDirSet = TRUE;
|
|
}
|
|
}
|
|
elseif ( $i === 3 and $bit === 'Upgrade' )
|
|
{
|
|
$bit = \mb_strtolower( $bit );
|
|
}
|
|
elseif( $sourcesDirSet === FALSE )
|
|
{
|
|
if( $i === 0 )
|
|
{
|
|
$path .= \IPS\ROOT_PATH . '/system/';
|
|
$baseFileLocation = \IPS\ROOT_PATH;
|
|
}
|
|
elseif ( $i === 1 and $bit === 'Application' )
|
|
{
|
|
// do nothing
|
|
}
|
|
else
|
|
{
|
|
$path .= 'sources/';
|
|
}
|
|
$sourcesDirSet = TRUE;
|
|
}
|
|
|
|
$path .= "{$bit}/";
|
|
}
|
|
|
|
|
|
|
|
/* Load it */
|
|
$path = \substr( $path, 0, -1 ) . '.php';
|
|
|
|
/* An app contains a hook that extends a class, but the class it extends cannot be found */
|
|
if ( !file_exists( $path ) )
|
|
{
|
|
$path = \substr( $path, 0, -4 ) . \substr( $path, \strrpos( $path, '/' ) );
|
|
if ( !file_exists( $path ) )
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
/* Get the methods */
|
|
$baseFile = $path;
|
|
$baseMethods = static::getMethods( file_get_contents( $baseFile ) );
|
|
|
|
if ( $getDetails )
|
|
{
|
|
$details[ $baseFile ] = $baseMethods;
|
|
}
|
|
|
|
foreach ( $hooks as $hookData )
|
|
{
|
|
$hookFileComponents = explode( '/', $hookData['file'] );
|
|
$hookType = $hookFileComponents[0] === 'applications' ? 'app' : ( $hookFileComponents[0] === 'plugins' ? 'plugin' : null );
|
|
$appOrPluginDir = $hookFileComponents[1];
|
|
|
|
if ( $hookType === null )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
/* Skip if we don't care about this app */
|
|
if ( $shouldSkip[$hookType][$appOrPluginDir] ?? null )
|
|
{
|
|
continue;
|
|
}
|
|
elseif (
|
|
!\array_key_exists( $appOrPluginDir, $shouldSkip[$hookType] ) AND (
|
|
( ( $hookType === 'app' ) and \is_array( $appDirsToCheck ) and !\in_array( $appOrPluginDir, $appDirsToCheck ) ) OR
|
|
( ( $hookType === 'app' ) and !\is_array( $appDirsToCheck ) and \in_array( $appOrPluginDir, \IPS\IPS::$ipsApps ) ) OR
|
|
( \is_array( $pluginDirsToCheck ) and !\in_array( $appOrPluginDir, $pluginDirsToCheck ) ) )
|
|
)
|
|
{
|
|
$shouldSkip[$hookType][$appOrPluginDir] = 1;
|
|
continue;
|
|
}
|
|
$shouldSkip[$hookType][$appOrPluginDir] = 0;
|
|
|
|
$appOrPlugin = ( $hookType === 'app' ) ? 'App - ' : 'Plugin - ';
|
|
$issueFile = '/' . implode( '/', \array_slice( $hookFileComponents, 2 ) );
|
|
array_pop( $hookFileComponents );
|
|
$app = $appOrPlugin . $hookFileComponents[1];
|
|
|
|
if ( $hookBases[$hookType][$appOrPluginDir] ?? null )
|
|
{
|
|
$hookBase = $hookBases[$hookType][$appOrPluginDir];
|
|
}
|
|
else
|
|
{
|
|
$hookBase = \IPS\SITE_FILES_PATH;
|
|
if ( \IPS\CIC2 and $hookType === 'app' and \in_array( $appOrPluginDir, \IPS\IPS::$ipsApps ) )
|
|
{
|
|
$hookBase = \IPS\ROOT_PATH;
|
|
}
|
|
$hookBases[$hookType][$appOrPluginDir] = $hookBase;
|
|
}
|
|
$hookFile = $hookBase . '/' . $hookData['file'];
|
|
|
|
if ( !file_exists( $hookFile ) )
|
|
{
|
|
static::log( "The file {$hookFile} does not exist, so the hook belonging to {$app} was not checked." );
|
|
continue;
|
|
}
|
|
|
|
$hookMethods = static::getMethods( file_get_contents( $hookFile ) );
|
|
if ( $getDetails )
|
|
{
|
|
$details[$hookFile] = $hookMethods;
|
|
}
|
|
$priority = $hookData['isPriority'] ?? ( $hookType === 'app' ? !\in_array( $appOrPluginDir, \IPS\IPS::$ipsApps ) : true ); // Built in apps are not priority
|
|
|
|
/* Find any issues */
|
|
$hookIssues = static::compareMethodsForIssues(
|
|
$baseMethods,
|
|
$hookMethods,
|
|
$baseFile,
|
|
$hookFile,
|
|
$baseClass,
|
|
$baseClassNamespace . '\\' . $hookData['class'], /* The hook class shares the same namespace as the base; see \IPS\IPS::monkeyPatch() to understand */
|
|
$shallowCheck,
|
|
$limit,
|
|
true,
|
|
$priority
|
|
);
|
|
|
|
if ( !empty( $hookIssues ) )
|
|
{
|
|
if ( $shallowCheck )
|
|
{
|
|
if ( $priority OR !( $options['shallowCheckPriorityOnly'] ?? 1 ) )
|
|
{
|
|
return true;
|
|
}
|
|
$issues[] = 1;
|
|
continue;
|
|
}
|
|
|
|
$issues[$app] = $issues[$app] ?? [];
|
|
$issues[$app][$issueFile] = array_merge( $issues[$app][$issueFile] ?? [], $hookIssues );
|
|
|
|
if ( $limit !== -1 )
|
|
{
|
|
$limit -= \count( $hookIssues );
|
|
}
|
|
$count += \count( $hookIssues );
|
|
}
|
|
}
|
|
|
|
if ( $limit !== -1 AND $count >= $limit )
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( $shallowCheck )
|
|
{
|
|
return empty( $issues ) ? null : false; //only issues that could be here are not priority
|
|
}
|
|
|
|
return ( $getDetails ? [ $issues, $details ] : [ $issues ] );
|
|
}
|
|
|
|
/**
|
|
* Get Methods from a file's contents and details on each method
|
|
*
|
|
* @param string $scriptContents The contents of the PHP file; should be the entire file's contents
|
|
*
|
|
* @return array
|
|
* @example with a file containing public function foo( ?array $item )
|
|
* array(
|
|
"foo" => array(
|
|
"final" => false,
|
|
"security" => "public",
|
|
"static" => false,
|
|
"name" => "foo",
|
|
"parameters" => array(
|
|
"item" => array(
|
|
"name" => "item",
|
|
"type" => "IPS\Content\Item",
|
|
"nullable" => true,
|
|
"passedByReference" => false,
|
|
"packed" => false
|
|
)
|
|
),
|
|
"returnType" => "bool",
|
|
"lineNumber" => 384
|
|
)
|
|
)
|
|
*/
|
|
final protected static function getMethods( string $scriptContents ): array
|
|
{
|
|
$cacheKey = md5( $scriptContents );
|
|
if ( isset( static::$methodInfoCache[ $cacheKey ] ) )
|
|
{
|
|
return static::$methodInfoCache[ $cacheKey ];
|
|
}
|
|
$methods = array();
|
|
$pattern = '/(public\\s+|static\\s+|protected\\s+|private\\s+|final\\s+)*function\\s+[a-zA-Z0-9_]+\\s*\\(.*\\)[\\:\\sa-zA-Z0-9_\\\\]*\\{/';
|
|
|
|
try
|
|
{
|
|
$matches = array();
|
|
if ( preg_match_all( $pattern, $scriptContents, $matches, \PREG_OFFSET_CAPTURE ) and !empty( $matches[0] ) )
|
|
{
|
|
foreach ( $matches[0] as $methodDeclarationDetails )
|
|
{
|
|
$methodDeclaration = rtrim( $methodDeclarationDetails[0], '{' );
|
|
$components = explode( 'function', $methodDeclaration, 2 );
|
|
$prefix = preg_split( '/\s+/', $components[0] ) ?: array();
|
|
$suffix = $components[1]; // we are certain this exists given our regex pattern
|
|
|
|
$method = array(
|
|
'final' => \in_array( 'final', $prefix ),
|
|
'security' => \in_array( 'protected', $prefix ) ? 'protected' : ( \in_array( 'private', $prefix ) ? 'private' : 'public' ),
|
|
'static' => \in_array( 'static', $prefix ),
|
|
'name' => '',
|
|
'parameters' => array(),
|
|
'returnType' => null,
|
|
'lineNumber' => \count( explode("\n", \mb_substr( $scriptContents, 0, $methodDeclarationDetails[1] ) ) )
|
|
);
|
|
preg_match( '/^\\s*([a-zA-Z0-9_]+)/', $suffix, $names );
|
|
$method['name'] = $names[1];
|
|
|
|
//did we get a return type?
|
|
if ( $suffixComponents = explode( ':', $suffix ) and isset( $suffixComponents[1] ) )
|
|
{
|
|
$method['returnType'] = trim( $suffixComponents[1] ) ?: null;
|
|
}
|
|
|
|
/* Remove arrays from parameters since they will break things later Multidimensional arrays will still break things, but idc because this works on core/our MS code */
|
|
$noArrays = preg_replace( ['/array\\(.*?\\)/', '/\\[[^\s]+?\\]/'], ['',''], trim( \mb_substr( $suffix, \strlen( $method['name'] ) - 1 ) ) );
|
|
preg_match( '/\\(\\s*([^\\)]*)\\s*\\)/', $noArrays, $paramComponents );
|
|
$method['parameters'] = static::getParameterDetails( $paramComponents[1] );
|
|
$methods[$method['name']] = $method;
|
|
}
|
|
}
|
|
}
|
|
catch ( \Exception $e ) { }
|
|
|
|
static::$methodInfoCache[$cacheKey] = $methods;
|
|
return $methods;
|
|
}
|
|
|
|
/**
|
|
* Get Methods from a file's contents and details on each method
|
|
*
|
|
* @param string $scriptContents The contents of the PHP file; should be the entire file's contents
|
|
*
|
|
* @return array
|
|
*/
|
|
final public static function getClassDetails( string $scriptContents )
|
|
{
|
|
$cacheKey = md5( $scriptContents );
|
|
if ( array_key_exists( $cacheKey, static::$classDetailsCache ) )
|
|
{
|
|
return static::$classDetailsCache[$cacheKey];
|
|
}
|
|
|
|
$details = array(
|
|
'className' => null,
|
|
'fileNamespace' => '\\',
|
|
'classNamespace' => '\\',
|
|
'globalClassName' => null,
|
|
'parentClass' => null,
|
|
'abstract' => false,
|
|
'interfaces' => array(),
|
|
'methods' => array(),
|
|
);
|
|
|
|
$pattern = '/(namespace\\s+[a-zA-Z\\\\_0-9]+?\\s*;(?:.|\\n)*?)?.*?(?:^|\\n)\\s*(abstract\\s+?)?\\s*\\bclass\\s+([a-zA-Z0-9_\\\\]+)(\\s+?extends\\s+[\\\\A-Za-z0-9_]+\\s+)?([^{]+?)?\\s+\\{/';
|
|
$matches = array();
|
|
if ( preg_match( $pattern, $scriptContents, $matches ) and !empty( $matches ) ) // we don't need preg_match_all since we're assuming 1 class per file
|
|
{
|
|
array_shift($matches);
|
|
$component = trim( array_shift( $matches ) );
|
|
|
|
/* Namespace found? */
|
|
if ( \mb_substr( $component, 0, 9 ) === 'namespace' )
|
|
{
|
|
$namespaceDeclaration = explode( ';', $component, 2 )[0];
|
|
$details['fileNamespace'] = '\\' . trim( str_replace( 'namespace', '', $namespaceDeclaration ), "\\ \n\r\t\v\x00" );
|
|
$component = trim( array_shift( $matches ) );
|
|
}
|
|
|
|
/* Is it abstract */
|
|
if ( $component === 'abstract' )
|
|
{
|
|
$details['abstract'] = true;
|
|
$component = trim( array_shift( $matches ) );
|
|
}
|
|
elseif ( empty( $component ) )
|
|
{
|
|
$component = trim( array_shift( $matches ) );
|
|
}
|
|
|
|
/* What's the name */
|
|
$classComponents = explode( '\\', trim( $component ) );
|
|
$details['className'] = array_pop( $classComponents );
|
|
|
|
/* If it's globally namespaced, don't use the file namespace */
|
|
if ( @$classComponents[0] === 'IPS' OR \mb_substr( $component, 0, 1 ) === '\\' )
|
|
{
|
|
$imploded = implode( '\\', $classComponents );
|
|
$details['classNamespace'] = '\\' . $imploded;
|
|
}
|
|
else
|
|
{
|
|
$details['classNamespace'] = '\\' . trim( $details['fileNamespace'], '\\' );
|
|
if ( \count( $classComponents ) )
|
|
{
|
|
$details['classNamespace'] .= '\\' . implode( '\\', $classComponents );
|
|
}
|
|
}
|
|
|
|
/* We can remove the leading underscore for IPS stuff */
|
|
if ( \mb_substr( $details['classNamespace'], 0, 4 ) === '\\IPS' and \mb_substr( $details['className'], 0, 1 ) === '_' )
|
|
{
|
|
$details['className'] = \mb_substr( $details['className'], 1 );
|
|
}
|
|
|
|
$details['globalClassName'] = $details['classNamespace'] . '\\' . $details['className'];
|
|
$component = trim( array_shift( $matches ) );
|
|
if ( empty( $component ) )
|
|
{
|
|
$component = trim( array_shift( $matches ) );
|
|
}
|
|
|
|
/* Does it extend? */
|
|
if ( \mb_substr( $component, 0, 7 ) === 'extends' )
|
|
{
|
|
$component = trim( str_replace( 'extends', '', $component ) );
|
|
|
|
if ( \mb_substr( $component, 0, 1 ) !== '\\' AND \mb_substr( $component, 0, 3 ) !== 'IPS' )
|
|
{
|
|
$component = rtrim( $details['fileNamespace'], '\\' ) . '\\' . trim( $component, '\\' );
|
|
}
|
|
|
|
$details['parentClass'] = $component;
|
|
$component = trim( array_shift( $matches ) );
|
|
}
|
|
elseif ( empty( $component ) )
|
|
{
|
|
$component = trim( array_shift( $matches ) );
|
|
}
|
|
|
|
/* Interfaces? */
|
|
$parts = explode( 'implements', $component );
|
|
$interfacesComponent = array_pop( $parts );
|
|
/* We got interfaces if removing the implements keyword changed the length */
|
|
if ( \mb_strlen( $interfacesComponent ) !== \mb_strlen( $component ) )
|
|
{
|
|
$interfaceComponents = explode( ',', $interfacesComponent );
|
|
$interfaceComponents = array_map( 'trim', $interfaceComponents );
|
|
while ( \count( $interfaceComponents ) AND preg_match( "/^[\\\\_a-zA-Z0-9]+\$/", $interfaceComponents[0] ) )
|
|
{
|
|
$details['interfaces'][] = array_shift( $interfaceComponents );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $details['globalClassName'] )
|
|
{
|
|
$details['methods'] = static::getMethods( $scriptContents );
|
|
}
|
|
|
|
static::$classDetailsCache[$cacheKey] = $details;
|
|
return $details;
|
|
}
|
|
|
|
/**
|
|
* Get details for the parameter string (what's in the parenthesis following the method name)
|
|
* NOTE - This will break if you have a comma-delimited array as a default param; we don't care about that enough to fix it though
|
|
*
|
|
* @param string $parameterString The raw string of parameters from a method declaration
|
|
*
|
|
* @return array
|
|
*/
|
|
final protected static function getParameterDetails( string $parameterString ) : array
|
|
{
|
|
$parameters = array();
|
|
if ( empty( trim( $parameterString ) ) )
|
|
{
|
|
return array();
|
|
}
|
|
|
|
$entities = explode( ',', $parameterString );
|
|
|
|
foreach( $entities as $entity )
|
|
{
|
|
$parameter = array(
|
|
"name" => "",
|
|
"type" => null,
|
|
"nullable" => false,
|
|
"passedByReference" => false,
|
|
"packed" => false
|
|
);
|
|
|
|
$entity = trim($entity);
|
|
|
|
// is it packed?
|
|
$unpacked = str_replace( '...', '', $entity );
|
|
$parameter['packed'] = $entity !== $unpacked;
|
|
$entity = $unpacked;
|
|
|
|
// is it by reference?
|
|
$unreferenced = str_replace( '&', '', $entity );
|
|
$parameter['passedByReference'] = $unreferenced !== $entity;
|
|
$entity = $unreferenced;
|
|
|
|
// is it nullable?
|
|
$nonNullable = str_replace( '?', '', $entity );
|
|
$parameter['nullable'] = $entity !== $nonNullable;
|
|
$entity = $nonNullable;
|
|
|
|
// var name
|
|
preg_match( '/\\$([a-zA-Z0-9_]+)\\s*=?\\s*(null|NULL)?/', $entity, $matches );
|
|
$parameter['name'] = $matches[1];
|
|
$defaultValue = isset( $matches[2] ) ? trim( $matches[2] ) : null;
|
|
|
|
// to get the type, capture everything before the name
|
|
$pattern = '/^(.*)\s+\\$' . $parameter['name'] . '/';
|
|
preg_match( $pattern, $entity, $matches );
|
|
if ( isset( $matches[1] ) )
|
|
{
|
|
$parameter['type'] = trim( $matches[1] );
|
|
}
|
|
|
|
/* If the default is null, it is still nullable. */
|
|
if ( $parameter['type'] and $defaultValue and \mb_strtolower( $defaultValue ) === 'null' )
|
|
{
|
|
$parameter['nullable'] = true;
|
|
}
|
|
|
|
$parameters[$parameter['name']] = $parameter;
|
|
}
|
|
|
|
return $parameters;
|
|
}
|
|
|
|
/**
|
|
* Log any messages
|
|
*
|
|
* @param string $message
|
|
* @return void
|
|
*/
|
|
protected static function log( string $message )
|
|
{
|
|
if( static::$logging === TRUE )
|
|
{
|
|
\IPS\Log::debug( $message, 'app_scanner' );
|
|
}
|
|
}
|
|
} |