bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Utility/Php.php

Summary

Maintainability
A
0 mins
Test Coverage
B
89%
<?php

/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @version   v3.0
 */

namespace bdk\Debug\Utility;

use bdk\Debug\Utility\Reflection;
use Exception;
use UnitEnum;

/**
 * Php language utilities
 */
class Php
{
    const IS_CALLABLE_ARRAY_ONLY = 1;
    const IS_CALLABLE_OBJ_ONLY = 2;
    const IS_CALLABLE_SYNTAX_ONLY = 4;
    const IS_CALLABLE_NO_CALL = 8; // don't test for __call / __callStatic methods

    /** @var string[] list of allowed-to-be-unserialized classes passwed to userializeSafe */
    protected static $allowedClasses = array();

    /**
     * Get friendly classname for given classname or object
     * This is primarily useful for anonymous classes
     *
     * @param object|class-string $mixed Reflector instance, object, or classname
     *
     * @return string
     */
    public static function friendlyClassName($mixed)
    {
        $reflector = Reflection::getReflector($mixed, true);
        if ($reflector && \method_exists($reflector, 'getDeclaringClass')) {
            $reflector = $reflector->getDeclaringClass();
        }
        return self::getDebugTypeObject($reflector->getName());
    }

    /**
     * Gets the type name of a variable in a way that is suitable for debugging
     *
     * like php's `get_debug_type`, but will return
     *  - 'callable' for callable array
     *  - enum name for enum value
     *
     * @param mixed $val The value being type checked
     *
     * @return string
     *
     * @see https://github.com/symfony/polyfill/blob/main/src/Php80/Php80.php
     */
    public static function getDebugType($val) // phpcs:ignore Generic.Metrics.CyclomaticComplexity
    {
        if (PHP_VERSION_ID >= 80000 && \is_array($val) === false && \is_object($val) === false) {
            return \get_debug_type($val);
        }

        switch (true) {
            case $val === null:
                return 'null';
            case \is_bool($val):
                return 'bool';
            case \is_string($val):
                return 'string';
            case \is_array($val):
                return self::isCallable($val)
                    ? 'callable'
                    : 'array';
            case \is_int($val):
                return 'int';
            case \is_float($val):
                return 'float';
            case \is_object($val):
                return self::getDebugTypeObject($val);
            case $val instanceof \__PHP_Incomplete_Class:
                return '__PHP_Incomplete_Class';
            default:
                return self::getDebugTypeResource($val);
        }
    }

    /**
     * returns required/included files sorted by directory
     *
     * @return array
     */
    public static function getIncludedFiles()
    {
        $includedFiles = \get_included_files();
        \usort($includedFiles, static function ($valA, $valB) {
            $valA = \str_replace('_', '0', $valA);
            $valB = \str_replace('_', '0', $valB);
            $dirA = \dirname($valA);
            $dirB = \dirname($valB);
            return $dirA === $dirB
                ? \strnatcasecmp($valA, $valB)
                : \strnatcasecmp($dirA, $dirB);
        });
        return $includedFiles;
    }

    /**
     * Return path to the loaded php.ini file along with .ini files parsed from the additional ini dir
     *
     * @return array
     */
    public static function getIniFiles()
    {
        return \array_merge(
            array(\php_ini_loaded_file()),
            \array_filter(\preg_split('#\s*[,\r\n]+\s*#', \trim((string) \php_ini_scanned_files())))
        );
    }

    /**
     * Test if value is "callable"
     *
     * Like php's is_callable but
     *   * more options
     *   * stricter syntaxOnly option (test valid string labels)
     *   * does not test against current context
     *   * does not trigger autoloader
     *
     * @param string|array $val  value to check
     * @param int          $opts bitmask of IS_CALLABLE_x constants
     *                         IS_CALLABLE_ARRAY_ONLY
     *                             must be array(x, 'method')
     *                             (does not apply for Closure and invokable obj)
     *                         IS_CALLABLE_OBJ_ONLY
     *                             if array, first value must be object
     *                             (does not apply for Closure and invokable obj)
     *                         IS_CALLABLE_SYNTAX_ONLY
     *                             non-namespaced strings will ignore this flag and do full check
     *                         IS_CALLABLE_NO_CALL
     *                             don't test for __call / __callStatic method
     *
     * @return bool
     */
    public static function isCallable($val, $opts = 0)
    {
        if (\is_object($val)) {
            // test if Closure or obj with __invoke
            return \is_callable($val, false);
        }
        if (\is_array($val)) {
            return self::isCallableArray($val, $opts);
        }
        if ($opts & self::IS_CALLABLE_ARRAY_ONLY) {
            return false;
        }
        $syntaxOnly = \is_string($val) && \preg_match('/(::|\\\)/', $val) !== 1
            ? false // string without namespace: do a full check
            : ($opts & self::IS_CALLABLE_SYNTAX_ONLY) === self::IS_CALLABLE_SYNTAX_ONLY;
        return \is_callable($val, $syntaxOnly);
    }

    /**
     * Throwable is a PHP 7+ thing
     *
     * @param mixed $val Value to test
     *
     * @return bool
     */
    public static function isThrowable($val)
    {
        return $val instanceof \Error || $val instanceof Exception;
    }

    /**
     * Determine PHP's MemoryLimit
     *
     * @return string
     */
    public static function memoryLimit()
    {
        $iniVal = \trim(\ini_get('memory_limit') ?: \get_cfg_var('memory_limit'));
        return $iniVal ?: '128M';
    }

    /**
     * Unserialize while only allowing the specified classes to be unserialized
     *
     * stdClass will always be allowed
     *
     * Gracefully handle unsafe classes implementing Serializable
     *
     * @param string        $serialized     serialized string
     * @param string[]|bool $allowedClasses allowed class names
     *
     * @return mixed
     */
    public static function unserializeSafe($serialized, $allowedClasses = array())
    {
        if ($allowedClasses === true) {
            return \unserialize($serialized);
        }
        if ($allowedClasses === false) {
            $allowedClasses = array();
        }
        $allowedClasses[] = 'stdClass';
        self::$allowedClasses = \array_unique($allowedClasses);
        $hasSerializable = \preg_match('/(^|;)C:(\d+):"([\w\\\\]+)":(\d+):\{/', $serialized) === 1;
        if ($hasSerializable === false && PHP_VERSION_ID >= 70000) {
            return \unserialize($serialized, array(
                'allowed_classes' => self::$allowedClasses,
            ));
        }
        $serialized = self::unserializeSafeModify($serialized);
        return \unserialize($serialized);
    }

    /**
     * Get friendly class name
     *
     * @param object $obj Object to inspect
     *
     * @return string
     */
    private static function getDebugTypeObject($obj)
    {
        if ($obj instanceof UnitEnum) {
            return \get_class($obj) . '::' . $obj->name;
        }
        $class = \is_object($obj)
            ? \get_class($obj)
            : $obj;
        if (\strpos($class, '@') === false) {
            return $class;
        }
        $class = \get_parent_class($class) ?: \key(\class_implements($class)) ?: 'class';
        return $class . '@anonymous';
    }

    /**
     * Get resource type
     *
     * This method is only used for php < 8.0
     *
     * @param mixed $val Resource
     *
     * @return string
     */
    private static function getDebugTypeResource($val)
    {
        // @phpcs:ignore Squiz.WhiteSpace.ScopeClosingBrace
        \set_error_handler(static function () {});
        $type = \get_resource_type($val);
        \restore_error_handler();

        if ($type === null) {
            // closed resource (php < 7.2)
            $type = 'closed';
        }
        if ($type === 'Unknown') {
            $type = 'closed';
        }

        return 'resource (' . $type . ')';
    }

    /**
     * Test if array is a callable
     *
     * We will ignore current context
     *
     * @param array $val  array to test
     * @param int   $opts bitmask of IS_CALLABLE_x constants
     *
     * @return bool
     */
    private static function isCallableArray(array $val, $opts)
    {
        if (\is_callable($val, true) === false) {
            return false;
        }
        $regexLabel = '/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/';
        if (\preg_match($regexLabel, $val[1]) !== 1) {
            return false;
        }
        if (\is_object($val[0])) {
            return self::isCallableArrayObj($val, $opts);
        }
        if ($opts & self::IS_CALLABLE_OBJ_ONLY) {
            return false;
        }
        return self::isCallableArrayString($val, $opts);
    }

    /**
     * Test if array(obj, 'method') is callable
     *
     * @param array $val  array to test
     * @param int   $opts bitmask of IS_CALLABLE_x constants
     *
     * @return bool
     */
    private static function isCallableArrayObj(array $val, $opts)
    {
        if ($opts & self::IS_CALLABLE_SYNTAX_ONLY) {
            return true;
        }
        if (\method_exists($val[0], $val[1])) {
            return true;
        }
        return $opts & self::IS_CALLABLE_NO_CALL
            ? false
            : \method_exists($val[0], '__call');
    }

    /**
     * Test if array('string', 'method') is callable
     *
     * @param array $val  array to test
     * @param int   $opts bitmask of IS_CALLABLE_x constants
     *
     * @return bool
     */
    private static function isCallableArrayString(array $val, $opts)
    {
        if ($opts & self::IS_CALLABLE_SYNTAX_ONLY) {
            // is_callable syntaxOnly only tested if 1st val is obj or string
            //    we'll test that string is a valid label
            $regexClass = '/^(\\\\?[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)+$/';
            return \preg_match($regexClass, $val[0]) === 1;
        }
        if (\class_exists($val[0], false) === false) {
            // test if class exists before calling method_exists to avoid autoload attempt
            return false;
        }
        if (\method_exists($val[0], $val[1])) {
            return true;
        }
        return $opts & self::IS_CALLABLE_NO_CALL
            ? false
            : \method_exists($val[0], '__callStatic');
    }

    /**
     * Modify serialized string to remove classes that are not explicitly allowed
     *
     * @param string $serialized Output from `serialize()`
     *
     * @return string
     */
    private static function unserializeSafeModify($serialized)
    {
        $matches = array();
        $offset = 0;
        $regex = '/(^|;)([OC]):(\d+):"([\w\\\\]+)":(\d+):\{/';
        $regexKeys = array('full', 'prefix', 'type', 'strlen', 'classname', 'length');
        $serializedNew = '';
        while (\preg_match($regex, $serialized, $matches, PREG_OFFSET_CAPTURE, $offset)) {
            /** @var array<string, int> */
            $offsets = array();
            foreach ($regexKeys as $i => $key) {
                $matches[$key] = $matches[$i][0];
                $offsets[$key] = $matches[$i][1];
            }
            // only applicable to 'C' type
            $matches['data'] = $matches['type'] === 'C'
                ? \substr(
                    $serialized,
                    $offsets['length'] + \strlen($matches['length']) + 2, // length + xx + :{
                    $matches['length']
                )
                : null;
            $serializedNew .= \substr($serialized, $offset, $offsets['full'] - $offset);
            $serializedNew .= self::unserializeSafeModifyMatch($matches, $offsets, $offset);
        }
        return $serializedNew . \substr($serialized, $offset);
    }

    /**
     * Update object serialization
     *
     * @param array $matches match strings
     * @param array $offsets match offsets
     * @param int   $offset  Updated with new string offset
     *
     * @return string
     */
    private static function unserializeSafeModifyMatch($matches, $offsets, &$offset)
    {
        $offset = $offsets['full'] + \strlen($matches['full']);
        if (\strlen($matches['classname']) !== (int) $matches['strlen'] || \in_array($matches['classname'], self::$allowedClasses, true)) {
            return $matches['full'];
        }
        if ($matches['type'] === 'O') {
            return $matches['prefix']
                . 'O:22:"__PHP_Incomplete_Class":'
                . ($matches['length'] + 1)
                . ':{s:27:"__PHP_Incomplete_Class_Name";' . \serialize($matches['classname']);
        }
        // Object was serialized via Serializable interface
        $offset += $matches['length'] + 1;
        return $matches['prefix']
            . 'O:22:"__PHP_Incomplete_Class":'
            . \substr(\serialize((object) array(
                '__PHP_Incomplete_Class_Name' => $matches['classname'],
                '__serialized_data' => $matches['data'],
            )), \strlen('O:8:"stdClass":'));
    }
}