* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * PHP_Utils class. */ class PHP_Utils { /** * Timestamp when the current request started. * * @var int|null */ private static $request_start_time = null; /** * Initialize the request start time. * * Should be called as early as possible in the plugin lifecycle, * typically right after the autoloader is registered. * @since 1.0.0 */ public static function init_request_start_time() { if ( null === self::$request_start_time ) { self::$request_start_time = time(); } } /** * Get the elapsed time since the request started. * * @return int Elapsed time in seconds. */ private static function get_elapsed_time() { if ( null === self::$request_start_time ) { self::init_request_start_time(); return 0; } return time() - self::$request_start_time; } /** * Get available execution time for the current request. * * Calculates a portion of the configured max_execution_time, clamped * between minimum and maximum bounds, minus time already elapsed in the * current request. Useful for long-running operations that need to exit * gracefully before PHP's hard timeout. * * @param float $duty_cycle Fraction of max_execution_time to use (0.0-1.0). * @param int $min Minimum time limit in seconds. * @param int $max Maximum time limit in seconds. * @return int Available execution time in seconds. * @since 1.0.0 */ public static function get_available_execution_time( $duty_cycle = 0.8, $min = 15, $max = 300 ) { $max_execution_time = (int) ini_get( 'max_execution_time' ); // If unlimited (0), assume a reasonable default if ( 0 === $max_execution_time ) { $max_execution_time = 300; } $elapsed = self::get_elapsed_time(); $safe_time = (int) ( $max_execution_time * $duty_cycle ) - $elapsed; return max( $min, min( $max, $safe_time ) ); } /** * Get a human-readable name for a callback. * * @param mixed $callback The callback to analyze. * @return string The callback name (e.g., "function_name", "Class::method", or "Closure"). * @since 1.0.0 */ public static function get_callback_name( $callback ) { if ( is_string( $callback ) ) { return $callback; } if ( is_array( $callback ) && count( $callback ) === 2 ) { if ( is_object( $callback[0] ) ) { return get_class( $callback[0] ) . '::' . $callback[1]; } elseif ( is_string( $callback[0] ) ) { return $callback[0] . '::' . $callback[1]; } } if ( $callback instanceof \Closure ) { return 'Closure'; } return 'Unknown'; } /** * Get a reflection object for a callback. * * @param mixed $callback The callback to reflect on. * @return \ReflectionFunctionAbstract|null The reflection object, or null if reflection failed. * @since 1.0.0 */ public static function get_callback_reflection( $callback ) { try { if ( is_string( $callback ) && function_exists( $callback ) ) { return new \ReflectionFunction( $callback ); } if ( is_array( $callback ) && count( $callback ) === 2 ) { if ( is_object( $callback[0] ) ) { return new \ReflectionMethod( $callback[0], $callback[1] ); } elseif ( is_string( $callback[0] ) && method_exists( $callback[0], $callback[1] ) ) { return new \ReflectionMethod( $callback[0], $callback[1] ); } } if ( $callback instanceof \Closure ) { return new \ReflectionFunction( $callback ); } } catch ( \ReflectionException $e ) { return null; } return null; } /** * Validate PHP syntax without executing the code. * * Uses token_get_all() to parse PHP code and detect syntax errors. * This is safer than eval() as it does not execute the code. * * @param string $code The PHP code to validate. * @return true|string Returns true if syntax is valid, or error message string if invalid. * @since 1.0.0 */ public static function validate_php_syntax( $code ) { try { // Attempt to tokenize the PHP code // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Intentional: suppresses warnings for unclosed comments during syntax validation $tokens = @token_get_all( $code ); // Empty token array indicates parsing failure if ( empty( $tokens ) ) { return 'Failed to parse PHP code'; } return true; } catch ( \ParseError $e ) { // PHP 7+ throws ParseError for syntax errors return $e->getMessage(); } } /** * Canonicalize a path without filesystem access. * * Performs string-based path normalization: * - Uses DIRECTORY_SEPARATOR consistently * - On Windows: normalizes forward slashes to backslashes (both are valid separators) * - On Linux/Unix: only treats forward slash as separator (backslash is a valid filename character) * - Resolves . and .. segments * - Removes redundant separators * - Preserves leading .. for relative paths * * @param string $path The path to canonicalize. * @return string The canonicalized path. * @since 1.0.0 */ public static function canonicalize_path( $path ) { if ( '' === $path ) { return ''; } // On Windows, normalize forward slashes to backslashes (both are valid separators) // On Linux/Unix, leave backslashes alone (they're valid filename characters) if ( '\\' === DIRECTORY_SEPARATOR ) { $path = str_replace( '/', '\\', $path ); } // Check for Windows drive letter (e.g., C:\) $prefix = ''; if ( preg_match( '/^([a-zA-Z]):/', $path, $matches ) ) { $prefix = strtoupper( $matches[1] ) . ':'; $path = substr( $path, 2 ); } // Check if path is absolute $is_absolute = ( DIRECTORY_SEPARATOR === ( $path[0] ?? '' ) ); // Split into segments using DIRECTORY_SEPARATOR and process $segments = explode( DIRECTORY_SEPARATOR, $path ); $result = array(); foreach ( $segments as $segment ) { if ( '' === $segment || '.' === $segment ) { // Skip empty segments and current directory references continue; } if ( '..' === $segment ) { // Go up one directory if ( count( $result ) > 0 && '..' !== end( $result ) ) { array_pop( $result ); } elseif ( ! $is_absolute && '' === $prefix ) { // Preserve leading .. for relative paths (only if no drive letter) $result[] = '..'; } // For absolute paths, .. at root is silently ignored } else { $result[] = $segment; } } // Reconstruct the path using DIRECTORY_SEPARATOR $canonical = implode( DIRECTORY_SEPARATOR, $result ); // Add prefix for absolute paths or drive letters if ( '' !== $prefix ) { // Only add separator after drive letter if path was rooted (e.g., C:\path vs C:path) $canonical = $is_absolute ? $prefix . DIRECTORY_SEPARATOR . $canonical : $prefix . $canonical; } elseif ( $is_absolute ) { $canonical = DIRECTORY_SEPARATOR . $canonical; } // Handle edge case: empty result for root or drive-only paths if ( '' === $canonical && '' !== $prefix ) { // C:\ -> C:\, but C: -> C: $canonical = $is_absolute ? $prefix . DIRECTORY_SEPARATOR : $prefix; } elseif ( '' === $canonical && $is_absolute ) { $canonical = DIRECTORY_SEPARATOR; } return $canonical; } /** * Resolve a path that may not fully exist on the filesystem. * * Like realpath(), but works with paths where only a prefix exists. * Finds the longest existing prefix, resolves it with realpath(), * then appends the canonicalized non-existent suffix. * * @param string $path The path to resolve. * @return string|false The resolved absolute path, or false if no existing prefix found. * @since 1.0.0 */ public static function imaginary_path( $path ) { // Canonicalize the input path first (string-based) $canonical = self::canonicalize_path( $path ); if ( '' === $canonical ) { return false; } // If the full path exists, use realpath directly $real = realpath( $canonical ); if ( false !== $real ) { return $real; } // Find the longest existing prefix $check_path = $canonical; $non_existent = array(); $existing_checked = false; while ( '' !== $check_path && DIRECTORY_SEPARATOR !== $check_path ) { $real = realpath( $check_path ); if ( false !== $real ) { $existing_checked = true; break; } // Move the last segment to non-existent parts $non_existent[] = basename( $check_path ); $parent = dirname( $check_path ); // Prevent infinite loop if ( $parent === $check_path ) { break; } $check_path = $parent; } // If no existing prefix found, path is invalid if ( ! $existing_checked || false === $real ) { return false; } // Rebuild the non-existent suffix (reversed because we collected bottom-up) $non_existent = array_reverse( $non_existent ); // Build the final path $suffix = implode( DIRECTORY_SEPARATOR, $non_existent ); $result = '' !== $suffix ? $real . DIRECTORY_SEPARATOR . $suffix : $real; return $result; } /** * Check if a path is within a root directory. * * Both paths should be resolved absolute paths (via realpath() or imaginary_path()). * * @param string $path The resolved absolute path to check. * @param string $root The resolved absolute root directory path. * @return bool True if path is within root (or is root itself), false otherwise. * @since 1.0.0 */ public static function is_path_within( $path, $root ) { $root_prefix = rtrim( $root, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR; return $path === $root || strncmp( $path, $root_prefix, strlen( $root_prefix ) ) === 0; } }