woocommerce/tests/Tools/CodeHacking/CodeHacker.php

530 lines
15 KiB
PHP

<?php
/**
* CodeHacker class file.
*
* @package WooCommerce/Testing
*/
//phpcs:disable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;
use \ReflectionObject;
use \ReflectionFunction;
use \ReflectionException;
/**
* CodeHacker - allows to hack (alter on the fly) the content of PHP code files.
*
* Based on BypassFinals: https://github.com/dg/bypass-finals
*
* How to use:
*
* 1. Register hacks using CodeHacker::add_hack(hack). A hack is either:
* - A function with 'hack($code, $path)' signature, or
* - An object having a public 'hack($code, $path)' method.
*
* Where $code is a string containing the code to hack, and $path is the full path of the file
* containing the code. The function/method must return a string with the code already hacked.
*
* 2. Run CodeHacker::enable()
*
* For using with PHPUnit, see CodeHackerTestHook.
*/
final class CodeHacker {
const PROTOCOL = 'file';
const HACK_CALLBACK_ARGUMENT_COUNT = 2;
/**
* Value of "context" parameter to be passed to the native PHP filesystem related functions.
*
* @var mixed
*/
public $context;
/**
* File handle of the file that is open.
*
* @var mixed
*/
private $handle;
/**
* Optional white list of files to hack, if empty all the files will be hacked.
*
* @var array
*/
private static $path_white_list = array();
/**
* Registered hacks.
*
* @var array
*/
private static $hacks = array();
/**
* Registered persistent hacks.
*
* @var array
*/
private static $persistent_hacks = array();
/**
* Is the code hacker enabled?.
*
* @var bool
*/
private static $enabled = false;
/**
* Enable the code hacker.
*/
public static function enable() {
if ( ! self::$enabled ) {
stream_wrapper_unregister( self::PROTOCOL );
stream_wrapper_register( self::PROTOCOL, __CLASS__ );
self::$enabled = true;
}
}
/**
* Disable the code hacker.
*/
public static function disable() {
if ( self::$enabled ) {
stream_wrapper_restore( self::PROTOCOL );
self::$enabled = false;
}
}
/**
* Unregister all the non-persistent registered hacks.
*/
public static function clear_hacks() {
self::$hacks = self::$persistent_hacks;
}
/**
* Check if the code hacker is enabled.
*
* @return bool True if the code hacker is enabled.
*/
public static function is_enabled() {
return self::$enabled;
}
/**
* Check if persistent hacks have been registered.
*
* @return bool True if persistent hacks have been registered.
*/
public static function has_persistent_hacks() {
return count( self::$persistent_hacks ) > 0;
}
/**
* Register a new hack.
*
* @param mixed $hack A function with signature "hack($code, $path)" or an object containing a method with that signature.
* @param bool $persistent If true, the hack will be registered as persistent (so that clear_hacks will not clear it).
* @throws \Exception Invalid input.
*/
public static function add_hack( $hack, $persistent = false ) {
if ( ! is_callable( $hack ) && ! is_object( $hack ) ) {
throw new \Exception( "CodeHacker::addhack: Hacks must be either functions, or objects having a 'process(\$text, \$path)' method." );
}
if ( ! self::is_valid_hack_callback( $hack ) && ! self::is_valid_hack_object( $hack ) ) {
throw new \Exception( "CodeHacker::addhack: Hacks must be either a function with a 'hack(\$code,\$path)' signature, or an object containing a public method 'hack' with that signature. " );
}
if ( $persistent ) {
self::$persistent_hacks[] = $hack;
}
self::$hacks[] = $hack;
}
/**
* Check if the supplied argument is a valid hack callback (has two mandatory arguments).
*
* @param mixed $callback Argument to check.
*
* @return bool true if the argument is a valid hack callback, false otherwise.
* @throws ReflectionException Error when instantiating ReflectionFunction.
*/
private static function is_valid_hack_callback( $callback ) {
return is_callable( $callback ) && HACK_CALLBACK_ARGUMENT_COUNT === ( new ReflectionFunction( $callback ) )->getNumberOfRequiredParameters();
}
/**
* Check if the supplied argument is a valid hack object (has a public "hack" method with two mandatory arguments).
*
* @param mixed $callback Argument to check.
*
* @return bool rue if the argument is a valid hack object, false otherwise.
*/
private static function is_valid_hack_object( $callback ) {
if ( ! is_object( $callback ) ) {
return false;
}
$ro = new ReflectionObject( ( $callback ) );
try {
$rm = $ro->getMethod( 'hack' );
return $rm->isPublic() && ! $rm->isStatic() && 2 === $rm->getNumberOfRequiredParameters();
} catch ( ReflectionException $exception ) {
return false;
}
}
/**
* Set the white list of files to hack. If note set, all the PHP files will be hacked.
*
* @param array $path_white_list Paths of the files to hack, can be relative paths.
*/
public static function set_white_list( array $path_white_list ) {
self::$path_white_list = $path_white_list;
}
/**
* Close directory handle.
*/
public function dir_closedir() {
closedir( $this->handle );
}
/**
* Open directory handle.
*
* @param string $path Specifies the URL that was passed to opendir().
* @param int $options Whether or not to enforce safe_mode (0x04).
*
* @return bool TRUE on success or FALSE on failure.
*/
public function dir_opendir( $path, $options ) {
$this->handle = $this->context
? $this->native( 'opendir', $path, $this->context )
: $this->native( 'opendir', $path );
return (bool) $this->handle;
}
/**
* Read entry from directory handle.
*
* @return false|string string representing the next filename, or FALSE if there is no next file.
*/
public function dir_readdir() {
return readdir( $this->handle );
}
/**
* Rewind directory handle.
*
* @return TRUE on success or FALSE on failure.
*/
public function dir_rewinddir() {
return rewinddir( $this->handle );
}
/**
* Create a directory.
*
* @param string $path Directory which should be created.
* @param int $mode The value passed to mkdir().
* @param int $options A bitwise mask of values, such as STREAM_MKDIR_RECURSIVE.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function mkdir( $path, $mode, $options ) {
$recursive = (bool) ( $options & STREAM_MKDIR_RECURSIVE );
return $this->native( 'mkdir', $path, $mode, $recursive, $this->context );
}
/**
* Renames a file or directory.
*
* @param string $path_from The URL to the current file.
* @param string $path_to The URL which the path_from should be renamed to.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function rename( $path_from, $path_to ) {
return $this->native( 'rename', $path_from, $path_to, $this->context );
}
/**
* Removes a directory.
*
* @param string $path The directory URL which should be removed.
* @param int $options A bitwise mask of values, such as STREAM_MKDIR_RECURSIVE.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function rmdir( $path, $options ) {
return $this->native( 'rmdir', $path, $this->context );
}
/**
* Retrieve the underlying resource.
*
* @param mixed $cast_as Can be STREAM_CAST_FOR_SELECT when stream_select() is calling stream_cast() or STREAM_CAST_AS_STREAM when stream_cast() is called for other uses.
*
* @return mixed The underlying stream resource used by the wrapper, or FALSE.
*/
public function stream_cast( $cast_as ) {
return $this->handle;
}
/**
* Close a resource.
*/
public function stream_close() {
fclose( $this->handle );
}
/**
* Tests for end-of-file on a file pointer.
*
* @return bool TRUE if the read/write position is at the end of the stream and if no more data is available to be read, or FALSE otherwise.
*/
public function stream_eof() {
return feof( $this->handle );
}
/**
* Flushes the output.
*
* @return bool TRUE if the cached data was successfully stored (or if there was no data to store), or FALSE if the data could not be stored.
*/
public function stream_flush() {
return fflush( $this->handle );
}
/**
* Advisory file locking.
*
* @param int $operation LOCK_SH, LOCK_EX, LOCK_UN, or LOCK_NB.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function stream_lock( $operation ) {
return $operation
? flock( $this->handle, $operation )
: true;
}
/**
* Change stream metadata.
*
* @param string $path The file path or URL to set metadata. Note that in the case of a URL, it must be a :// delimited URL. Other URL forms are not supported.
* @param int $option STREAM_META_TOUCH, STREAM_META_OWNER_NAME, STREAM_META_OWNER, STREAM_META_GROUP_NAME, STREAM_META_GROUP, or STREAM_META_ACCESS.
* @param mixed $value Depends on $option.
*
* @return bool TRUE on success or FALSE on failure. If option is not implemented, FALSE should be returned.
*/
public function stream_metadata( $path, $option, $value ) {
switch ( $option ) {
case STREAM_META_TOUCH:
$value += array( null, null );
return $this->native( 'touch', $path, $value[0], $value[1] );
case STREAM_META_OWNER_NAME:
case STREAM_META_OWNER:
return $this->native( 'chown', $path, $value );
case STREAM_META_GROUP_NAME:
case STREAM_META_GROUP:
return $this->native( 'chgrp', $path, $value );
case STREAM_META_ACCESS:
return $this->native( 'chmod', $path, $value );
}
}
/**
* Opens file or URL. Note that this is where the hacking actually happens.
*
* @param string $path Specifies the URL that was passed to the original function.
* @param string $mode The mode used to open the file, as detailed for fopen().
* @param int $options Holds additional flags set by the streams API: STREAM_USE_PATH, STREAM_REPORT_ERRORS.
* @param string $opened_path If the path is opened successfully, and STREAM_USE_PATH is set in options, opened_path should be set to the full path of the file/resource that was actually opened.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function stream_open( $path, $mode, $options, &$opened_path ) {
$use_path = (bool) ( $options & STREAM_USE_PATH );
if ( 'rb' === $mode && self::path_in_white_list( $path ) && 'php' === pathinfo( $path, PATHINFO_EXTENSION ) ) {
$content = $this->native( 'file_get_contents', $path, $use_path, $this->context );
if ( false === $content ) {
return false;
}
$modified = self::hack( $content, $path );
if ( $modified !== $content ) {
$this->handle = tmpfile();
$this->native( 'fwrite', $this->handle, $modified );
$this->native( 'fseek', $this->handle, 0 );
return true;
}
}
$this->handle = $this->context
? $this->native( 'fopen', $path, $mode, $use_path, $this->context )
: $this->native( 'fopen', $path, $mode, $use_path );
return (bool) $this->handle;
}
/**
* Read from stream.
*
* @param int $count How many bytes of data from the current position should be returned.
*
* @return false|string If there are less than count bytes available, return as many as are available. If no more data is available, return either FALSE or an empty string.
*/
public function stream_read( $count ) {
return fread( $this->handle, $count );
}
/**
* Seeks to specific location in a stream.
*
* @param int $offset The stream offset to seek to.
* @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END.
*
* @return bool TRUE if the position was updated, FALSE otherwise.
*/
public function stream_seek( $offset, $whence = SEEK_SET ) {
return fseek( $this->handle, $offset, $whence ) === 0;
}
/**
* Change stream options.
*
* @param int $option STREAM_OPTION_BLOCKING, STREAM_OPTION_READ_TIMEOUT, or STREAM_OPTION_WRITE_BUFFER.
* @param int $arg1 Depends on $option.
* @param int $arg2 Depends on $option.
*/
public function stream_set_option( $option, $arg1, $arg2 ) {
}
/**
* Retrieve information about a file resource.
*
* @return array See stat().
*/
public function stream_stat() {
return fstat( $this->handle );
}
/**
* Retrieve the current position of a stream.
*
* @return false|int The current position of the stream.
*/
public function stream_tell() {
return ftell( $this->handle );
}
/**
* Truncate stream.
*
* @param int $new_size The new size.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function stream_truncate( $new_size ) {
return ftruncate( $this->handle, $new_size );
}
/**
* Write to stream.
*
* @param string $data Should be stored into the underlying stream.
*
* @return false|int The number of bytes that were successfully stored, or 0 if none could be stored.
*/
public function stream_write( $data ) {
return fwrite( $this->handle, $data );
}
/**
* Delete a file.
*
* @param string $path The file URL which should be deleted.
*
* @return bool TRUE on success or FALSE on failure.
*/
public function unlink( $path ) {
return $this->native( 'unlink', $path );
}
/**
* Retrieve information about a file.
*
* @param string $path The file path or URL to stat. Note that in the case of a URL, it must be a :// delimited URL. Other URL forms are not supported.
* @param int $flags Holds additional flags set by the streams API. It can hold one or more of the following values OR'd together.
*
* @return mixed Should return as many elements as stat() does. Unknown or unavailable values should be set to a rational value (usually 0). Pay special attention to mode as documented under stat().
*/
public function url_stat( $path, $flags ) {
$func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat';
return $flags & STREAM_URL_STAT_QUIET
? @$this->native( $func, $path )
: $this->native( $func, $path );
}
/**
* Executes a native PHP function.
*
* @param string $func Name of the function to execute. Pass the arguments for the PHP function after this one.
*
* @return mixed Return value from the native PHP function.
*/
private function native( $func ) {
stream_wrapper_restore( self::PROTOCOL );
$res = call_user_func_array( $func, array_slice( func_get_args(), 1 ) );
stream_wrapper_unregister( self::PROTOCOL );
stream_wrapper_register( self::PROTOCOL, __CLASS__ );
return $res;
}
/**
* Apply the reigstered hacks to the contents of a file.
*
* @param string $code Code content to hack.
* @param string $path Path of the file being hacked.
*
* @return string The code after applying all the registered hacks.
*/
private static function hack( $code, $path ) {
foreach ( self::$hacks as $hack ) {
if ( is_callable( $hack ) ) {
$code = call_user_func( $hack, $code, $path );
} else {
$code = $hack->hack( $code, $path );
}
}
return $code;
}
/**
* Check if a file path is in the white list.
*
* @param string $path File path to check.
*
* @return bool TRUE if there's an entry in the white list that ends with $path, FALSE otherwise.
*/
private static function path_in_white_list( $path ) {
if ( empty( self::$path_white_list ) ) {
return true;
}
foreach ( self::$path_white_list as $white_list_item ) {
if ( substr( $path, -strlen( $white_list_item ) ) === $white_list_item ) {
return true;
}
}
return false;
}
}
//phpcs:enable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged