Invision Power Services, Inc. (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\\...", "parent" => "\IPS\\...", "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' ); } } }