├── tests ├── data.phar ├── data │ └── addOne.php ├── TestCase.php ├── IncludePath │ └── IncludePathTest.php ├── FileFilterTest.php ├── InterceptorTest.php └── StreamTest.php ├── .gitignore ├── phpunit.xml.dist ├── composer.json ├── .github └── workflows │ └── main.yml ├── README.md └── src ├── Interceptor.php ├── FileFilter.php └── Stream.php /tests/data.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikic/include-interceptor/HEAD/tests/data.phar -------------------------------------------------------------------------------- /tests/data/addOne.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests/ 5 | 6 | 7 | 8 | 9 | src/ 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nikic/include-interceptor", 3 | "description": "Intercept PHP includes", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Robin Appelman", 8 | "email": "icewind@owncloud.com" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=7.1" 13 | }, 14 | "require-dev": { 15 | "phpunit/phpunit": "^7 || ^8", 16 | "phpstan/phpstan": "^1.2" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "Nikic\\IncludeInterceptor\\": "src/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Nikic\\IncludeInterceptor\\Tests\\": "tests/" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | run: 9 | strategy: 10 | matrix: 11 | operating-system: [ubuntu-latest, windows-latest] 12 | php-versions: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] 13 | runs-on: ${{ matrix.operating-system }} 14 | name: PHP ${{ matrix.php-versions }} on ${{ matrix.operating-system }} 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v1 18 | 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: ${{ matrix.php-versions }} 23 | extensions: mbstring 24 | 25 | - name: Install Dependencies 26 | run: composer install --prefer-dist 27 | 28 | - name: Run tests 29 | run: vendor/bin/phpunit 30 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 4 | * This file is licensed under the Licensed under the MIT license: 5 | * http://opensource.org/licenses/MIT 6 | */ 7 | 8 | namespace Nikic\IncludeInterceptor\Tests; 9 | 10 | abstract class TestCase extends \PHPUnit\Framework\TestCase { 11 | private $tmpFiles = []; 12 | 13 | public function tearDown(): void { 14 | parent::tearDown(); 15 | foreach ($this->tmpFiles as $file) { 16 | if (is_file($file)) { 17 | unlink($file); 18 | } 19 | } 20 | } 21 | 22 | protected function tempNam($postFix = '') { 23 | $id = uniqid(); 24 | $file = tempnam(sys_get_temp_dir(), $id . $postFix); 25 | $tmpFiles[] = $file; 26 | return $file; 27 | } 28 | 29 | /** 30 | * @param resource $stream 31 | * @return callable 32 | */ 33 | protected function loadCode($stream) { 34 | $file = $this->tempNam('.php'); 35 | file_put_contents($file, $stream); 36 | try { 37 | return include $file; 38 | } finally { 39 | unlink($file); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/IncludePath/IncludePathTest.php: -------------------------------------------------------------------------------- 1 | 4 | * This file is licensed under the Licensed under the MIT license: 5 | * http://opensource.org/licenses/MIT 6 | */ 7 | 8 | namespace Nikic\IncludeInterceptor\Tests\IncludePath; 9 | 10 | use Nikic\IncludeInterceptor\FileFilter; 11 | use Nikic\IncludeInterceptor\Interceptor; 12 | use Nikic\IncludeInterceptor\Tests\TestCase; 13 | 14 | class IncludePathTest extends TestCase { 15 | 16 | public function testInterceptFromOtherFolder() { 17 | $filter = FileFilter::createAllBlacklisted(); 18 | $filter->addWhiteList(dirname(__DIR__) . '/data'); 19 | $instance = new Interceptor(function(string $path) use ($filter) { 20 | if (!$filter->test($path)) return null; 21 | $code = file_get_contents($path); 22 | return str_replace('1', '2', $code); 23 | }); 24 | $instance->setUp(); 25 | 26 | /** @var callable $method */ 27 | $method = include '../data/addOne.php'; 28 | 29 | // Make sure a normal file_get_contents() works as well. 30 | $this->assertNotFalse(file_get_contents('../data/addOne.php')); 31 | 32 | $instance->tearDown(); 33 | 34 | $this->assertEquals(3, $method(1)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Include Interceptor 2 | 3 | Library to intercept PHP includes. A fork of [icewind1991/interceptor](https://github.com/icewind1991/interceptor). 4 | 5 | ``` 6 | composer require nikic/include-interceptor 7 | ``` 8 | 9 | ## Usage 10 | 11 | ```php 12 | use Nikic\IncludeInterceptor\Interceptor; 13 | 14 | $interceptor = new Interceptor(function(string $path) { 15 | if (!wantToIntercept($path)) { 16 | return null; 17 | } 18 | return transformCode(file_get_contents($path)); 19 | }); 20 | $interceptor->setUp(); // Start intercepting includes 21 | 22 | require 'src/foo.php'; 23 | 24 | $interceptor->tearDown(); // Stop intercepting includes 25 | ``` 26 | 27 | The interception hook follows the following contract: 28 | 29 | * It is only called if the included file exists. 30 | * The passed `$path` is the realpath. 31 | * If the hook returns `null`, no interception is performed. 32 | * If the hook returns a string, these are taken as the transformed content of the file. 33 | 34 | For convenience, a `FileFilter` is provided that implements white- and black-listing 35 | of files and directories. Paths passed to `addBlackList()` and `addWhiteList()` should 36 | always be realpaths. 37 | 38 | ```php 39 | use Nikic\IncludeInterceptor\Interceptor; 40 | use Nikic\IncludeInterceptor\FileFilter; 41 | 42 | $fileFilter = FileFilter::createAllWhitelisted(); // Start with everything whitelisted 43 | $fileFilter->addBlackList(__DIR__ . '/src/'); // Blacklist the src/ directory 44 | $fileFilter->addWhiteList(__DIR__ . '/src/foo.php'); // But whitelist the src/foo.php file 45 | $interceptor = new Interceptor(function(string $path) use ($fileFilter) { 46 | if (!$fileFilter->test($path)) { 47 | return null; 48 | } 49 | return transformCode(file_get_contents($path)); 50 | }); 51 | $interceptor->setUp(); 52 | 53 | require 'src/foo.php'; 54 | ``` 55 | -------------------------------------------------------------------------------- /tests/FileFilterTest.php: -------------------------------------------------------------------------------- 1 | 4 | * This file is licensed under the Licensed under the MIT license: 5 | * http://opensource.org/licenses/MIT 6 | */ 7 | 8 | namespace Nikic\IncludeInterceptor\Tests; 9 | 10 | use Nikic\IncludeInterceptor\FileFilter; 11 | 12 | class FileFilterTest extends TestCase { 13 | public function filterProvider() { 14 | return [ 15 | ['/foo.txt', [], [], [], false], 16 | ['/foo.txt', [], [], ['txt'], false], 17 | ['/foo.txt', [''], [], [], true], 18 | ['/foo.txt', [''], [], ['php'], false], 19 | ['/foo.txt', [''], [], ['txt'], true], 20 | ['/bar/asd/foo.txt', [''], ['/bar'], ['txt'], false], 21 | ['/bar/asd/foo.txt', ['', '/bar/asd'], ['/bar'], ['txt'], true], 22 | ['/bar/asd/foo.txt', ['/bar/asd'], ['/bar'], ['txt'], true], 23 | ['/bar/asd/foo.txt', ['/bar/asd'], ['/bar/asd/foo'], ['txt'], true], 24 | ['/bar/asd/foo.txt', ['/bar'], ['/bar'], ['txt'], false], 25 | ['/bar/asd/foo.txt', ['/bar'], ['/bar/asd'], ['txt'], false], 26 | ['/bar/asd/foo.txt', ['/bar/asd/foo.txt'], [], ['txt'], true], 27 | ['/bar/asd/foo.txt', ['/bar/asd'], ['/bar/asd/foo.txt'], ['txt'], false], 28 | ]; 29 | } 30 | 31 | /** 32 | * @param string $path 33 | * @param string[] $whiteList 34 | * @param string[] $blackList 35 | * @param string[] $extensions 36 | * @param bool $expected 37 | * @dataProvider filterProvider 38 | */ 39 | public function testFilter($path, $whiteList, $blackList, $extensions, $expected) { 40 | $instance = new FileFilter(); 41 | foreach ($whiteList as $dir) { 42 | $instance->addWhiteList($dir); 43 | } 44 | foreach ($blackList as $dir) { 45 | $instance->addBlackList($dir); 46 | } 47 | foreach ($extensions as $extension) { 48 | $instance->addExtension($extension); 49 | } 50 | $this->assertEquals($expected, $instance->test($path)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Interceptor.php: -------------------------------------------------------------------------------- 1 | hook = $hook; 29 | $this->protocols = $protocols; 30 | } 31 | 32 | /** 33 | * Open a file and run it through the hook. 34 | * 35 | * @return resource|null 36 | * @internal 37 | */ 38 | public function intercept(string $path) { 39 | $result = ($this->hook)($path); 40 | if ($result === null) { 41 | return null; 42 | } 43 | 44 | $stream = fopen('php://temp', 'r+'); 45 | fwrite($stream, $result); 46 | rewind($stream); 47 | return $stream; 48 | } 49 | 50 | /** 51 | * Setup this instance to intercept include calls. 52 | */ 53 | public function setUp(): void { 54 | if (Stream::hasInterceptor()) { 55 | throw new \BadMethodCallException('An interceptor is already active'); 56 | } 57 | Stream::setInterceptor($this); 58 | $this->wrap(); 59 | } 60 | 61 | /** 62 | * Stop intercepting include calls. 63 | */ 64 | public function tearDown(): void { 65 | $this->unwrap(); 66 | Stream::clearInterceptor(); 67 | } 68 | 69 | /** 70 | * Register the stream wrapper. 71 | * 72 | * @internal 73 | */ 74 | public function wrap(): void { 75 | foreach ($this->protocols as $protocol) { 76 | stream_wrapper_unregister($protocol); 77 | stream_wrapper_register($protocol, Stream::class); 78 | } 79 | } 80 | 81 | /** 82 | * Unregister the stream wrapper. 83 | * 84 | * @internal 85 | */ 86 | public function unwrap(): void { 87 | foreach ($this->protocols as $protocol) { 88 | stream_wrapper_restore($protocol); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/FileFilter.php: -------------------------------------------------------------------------------- 1 | addWhiteList(''); 29 | return $filter; 30 | } 31 | 32 | public static function createAllBlacklisted(): FileFilter { 33 | return new self(); 34 | } 35 | 36 | public function addWhiteList(string $path): void { 37 | $this->whiteList[] = rtrim($this->normalizePath($path), '/'); 38 | } 39 | 40 | public function addBlackList(string $path): void { 41 | $this->blackList[] = rtrim($this->normalizePath($path), '/'); 42 | } 43 | 44 | public function addExtension(string $extension): void { 45 | $this->extensions[] = ltrim($extension, '.'); 46 | } 47 | 48 | public function test(string $path): bool { 49 | $path = $this->normalizePath($path); 50 | if (!$this->isValidExtension($path)) { 51 | return false; 52 | } 53 | return $this->isWhiteListed($path) > $this->isBlackListed($path); 54 | } 55 | 56 | /** 57 | * Check if a file has a whitelisted extension. 58 | */ 59 | private function isValidExtension(string $path): bool { 60 | if ($this->extensions === null) { 61 | return true; 62 | } 63 | $extension = pathinfo($path, PATHINFO_EXTENSION); 64 | return in_array($extension, $this->extensions); 65 | } 66 | 67 | /** 68 | * Check if a file is whitelisted. 69 | * 70 | * @return int the length of the longest white list match 71 | */ 72 | private function isWhiteListed(string $path): int { 73 | return $this->isListed($path, $this->whiteList); 74 | } 75 | 76 | /** 77 | * Check if a file is blacklisted. 78 | * 79 | * @return int the length of the longest black list match 80 | */ 81 | private function isBlackListed(string $path): int { 82 | return $this->isListed($path, $this->blackList); 83 | } 84 | 85 | private function isListed(string $path, array $list): int { 86 | $length = 0; 87 | foreach ($list as $item) { 88 | $itemLen = \strlen($item); 89 | // Check for exact file match. 90 | if ($item === $path) { 91 | return $itemLen; 92 | } 93 | // Check for directory match. 94 | if ($itemLen >= $length && $this->inDirectory($item, $path)) { 95 | $length = $itemLen + 1; // +1 for trailing / 96 | } 97 | } 98 | return $length; 99 | } 100 | 101 | /** 102 | * Check if a file is within a folder. 103 | */ 104 | private function inDirectory(string $directory, string $path): bool { 105 | return ($directory === '') || (substr($path, 0, strlen($directory) + 1) === $directory . '/'); 106 | } 107 | 108 | /* 109 | * Normalize to Unix-style path. 110 | */ 111 | private function normalizePath(string $path): string { 112 | return str_replace('\\', '/', $path); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/InterceptorTest.php: -------------------------------------------------------------------------------- 1 | 4 | * This file is licensed under the Licensed under the MIT license: 5 | * http://opensource.org/licenses/MIT 6 | */ 7 | 8 | namespace Nikic\IncludeInterceptor\Tests; 9 | 10 | use Nikic\IncludeInterceptor\FileFilter; 11 | use Nikic\IncludeInterceptor\Interceptor; 12 | use PHPUnit\Framework\Error\Warning; 13 | 14 | class InterceptorTest extends TestCase { 15 | public function testInterceptNoopHook() { 16 | $calledCode = ''; 17 | $method = $this->loadWithHook('addOne.php', function ($path) use (&$calledCode) { 18 | $code = file_get_contents($path); 19 | $calledCode = $code; 20 | return $code; 21 | }); 22 | $this->assertEquals(2, $method(1)); 23 | $this->assertEquals($calledCode, file_get_contents(__DIR__ . '/data/addOne.php')); 24 | } 25 | 26 | public function testInterceptSingleHook() { 27 | $method = $this->loadWithHook('addOne.php', function ($path) { 28 | $code = file_get_contents($path); 29 | return str_replace('1', '2', $code); 30 | }); 31 | $this->assertEquals(3, $method(1)); 32 | } 33 | 34 | /** 35 | * @return callable 36 | */ 37 | private function loadWithHook(string $file, callable $hook) { 38 | $source = __DIR__ . '/data/' . $file; 39 | $instance = new Interceptor($hook); 40 | $stream = $instance->intercept($source); 41 | return $this->loadCode($stream); 42 | } 43 | 44 | public function testIntercept() { 45 | $filter = FileFilter::createAllBlacklisted(); 46 | $filter->addWhiteList(__DIR__ . '/data'); 47 | $instance = new Interceptor(function (string $path) use ($filter) { 48 | if (!$filter->test($path)) return null; 49 | $code = file_get_contents($path); 50 | return str_replace('1', '2', $code); 51 | }); 52 | $instance->setUp(); 53 | 54 | /** @var callable $method */ 55 | $method = include 'data/addOne.php'; 56 | 57 | $instance->tearDown(); 58 | 59 | $this->assertEquals(3, $method(1)); 60 | } 61 | 62 | public function testPharIntercept() { 63 | $filter = FileFilter::createAllBlacklisted(); 64 | $filter->addWhiteList('phar://' . __DIR__ . '/data.phar'); 65 | $instance = new Interceptor(function (string $path) use ($filter) { 66 | if (!$filter->test($path)) return null; 67 | $code = file_get_contents($path); 68 | return str_replace('1', '2', $code); 69 | }); 70 | $instance->setUp(); 71 | 72 | /** @var callable $method */ 73 | $method = include 'phar://' . __DIR__ . '/../tests/data.phar/./addOne.php'; 74 | 75 | $instance->tearDown(); 76 | 77 | $this->assertEquals(3, $method(1)); 78 | } 79 | 80 | public function testNotExistingFile() { 81 | $instance = new Interceptor(function (string $path) { 82 | throw new \Exception('Should not be called!'); 83 | }); 84 | $instance->setUp(); 85 | 86 | try { 87 | $this->expectException(Warning::class); 88 | include __DIR__ . '/data/doesntExist.php'; 89 | } finally { 90 | $instance->tearDown(); 91 | } 92 | } 93 | 94 | public function testDoubleSetup() { 95 | $this->expectException(\BadMethodCallException::class); 96 | $instance = new Interceptor(function(string $path) { 97 | return null; 98 | }); 99 | 100 | $instance->setUp(); 101 | try { 102 | $instance->setUp(); 103 | } catch (\BadMethodCallException $e) { 104 | $instance->tearDown(); 105 | throw $e; 106 | } 107 | $instance->tearDown(); 108 | } 109 | 110 | public function testTearDownSetup() { 111 | $filter = FileFilter::createAllBlacklisted(); 112 | $filter->addWhiteList(__DIR__ . '/data'); 113 | $instance = new Interceptor(function (string $path) use ($filter) { 114 | if (!$filter->test($path)) return null; 115 | $code = file_get_contents($path); 116 | return str_replace('1', '2', $code); 117 | }); 118 | 119 | $instance->setUp(); 120 | 121 | /** @var callable $method1 */ 122 | $method1 = include 'data/addOne.php'; 123 | 124 | $instance->tearDown(); 125 | 126 | /** @var callable $method2 */ 127 | $method2 = include 'data/addOne.php'; 128 | 129 | $instance->setUp(); 130 | /** @var callable $method3 */ 131 | $method3 = include 'data/addOne.php'; 132 | 133 | $instance->tearDown(); 134 | 135 | $this->assertEquals(3, $method1(1)); 136 | $this->assertEquals(2, $method2(1)); 137 | $this->assertEquals(3, $method3(1)); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/StreamTest.php: -------------------------------------------------------------------------------- 1 | 4 | * This file is licensed under the Licensed under the MIT license: 5 | * http://opensource.org/licenses/MIT 6 | */ 7 | 8 | namespace Nikic\IncludeInterceptor\Tests; 9 | 10 | use Nikic\IncludeInterceptor\Interceptor; 11 | use Nikic\IncludeInterceptor\Stream; 12 | 13 | class StreamTest extends TestCase { 14 | protected function fopen($source, $mode) { 15 | $interceptor = $this->createDummyInterceptor(); 16 | Stream::setInterceptor($interceptor); 17 | $interceptor->wrap(); 18 | $wrapped = fopen($source, $mode); 19 | $interceptor->unwrap(); 20 | return $wrapped; 21 | } 22 | 23 | protected function opendir($source) { 24 | $interceptor = $this->createDummyInterceptor(); 25 | Stream::setInterceptor($interceptor); 26 | $interceptor->wrap(); 27 | $wrapped = opendir($source); 28 | $interceptor->unwrap(); 29 | return $wrapped; 30 | } 31 | 32 | public function testRead() { 33 | $file = $this->tempNam(); 34 | $source = fopen($file, 'w'); 35 | fwrite($source, 'foobar'); 36 | fclose($source); 37 | $wrapped = $this->fopen($file, 'r'); 38 | $this->assertEquals('foo', fread($wrapped, 3)); 39 | $this->assertEquals('bar', fread($wrapped, 3)); 40 | $this->assertEquals('', fread($wrapped, 3)); 41 | } 42 | 43 | public function testWrite() { 44 | $file = $this->tempNam(); 45 | $wrapped = $this->fopen($file, 'w'); 46 | $this->assertEquals(6, fwrite($wrapped, 'foobar')); 47 | fclose($wrapped); 48 | $source = fopen($file, 'r'); 49 | $this->assertEquals('foobar', stream_get_contents($source)); 50 | } 51 | 52 | public function testSeekTell() { 53 | $file = $this->tempNam(); 54 | $source = fopen($file, 'w'); 55 | fwrite($source, 'foobar'); 56 | fclose($source); 57 | $wrapped = $this->fopen($file, 'r'); 58 | $this->assertEquals(0, ftell($wrapped)); 59 | fseek($wrapped, 2); 60 | $this->assertEquals(2, ftell($wrapped)); 61 | fseek($wrapped, 2, SEEK_CUR); 62 | $this->assertEquals(4, ftell($wrapped)); 63 | fseek($wrapped, -1, SEEK_END); 64 | $this->assertEquals(5, ftell($wrapped)); 65 | } 66 | 67 | public function testStat() { 68 | $unwrapped = fopen(__FILE__, 'r'); 69 | $wrapped = $this->fopen(__FILE__, 'r'); 70 | $this->assertEquals(fstat($unwrapped), fstat($wrapped)); 71 | } 72 | 73 | public function testTruncate() { 74 | $file = $this->tempNam(); 75 | $source = fopen($file, 'w'); 76 | fwrite($source, 'foobar'); 77 | fclose($source); 78 | $wrapped = $this->fopen($file, 'r+'); 79 | ftruncate($wrapped, 2); 80 | $this->assertEquals('fo', fread($wrapped, 10)); 81 | } 82 | 83 | public function testLock() { 84 | $file = $this->tempNam(); 85 | $wrapped = $this->fopen($file, 'r+'); 86 | $this->assertTrue(flock($wrapped, LOCK_EX)); 87 | } 88 | 89 | public function testStreamOptions() { 90 | $file = $this->tempNam(); 91 | $wrapped = $this->fopen($file, 'r+'); 92 | stream_set_blocking($wrapped, 0); 93 | stream_set_timeout($wrapped, 1, 0); 94 | stream_set_write_buffer($wrapped, 0); 95 | $this->expectNotToPerformAssertions(); 96 | } 97 | 98 | public function testReadDir() { 99 | $source = opendir(__DIR__); 100 | $content = []; 101 | while (($name = readdir($source)) !== false) { 102 | $content[] = $name; 103 | } 104 | closedir($source); 105 | $wrapped = $this->opendir(__DIR__); 106 | $wrappedContent = []; 107 | while (($name = readdir($wrapped)) !== false) { 108 | $wrappedContent[] = $name; 109 | } 110 | $this->assertEquals($content, $wrappedContent); 111 | } 112 | 113 | public function testRewindDir() { 114 | $source = opendir(__DIR__); 115 | $content = []; 116 | while (($name = readdir($source)) !== false) { 117 | $content[] = $name; 118 | } 119 | closedir($source); 120 | $wrapped = $this->opendir(__DIR__); 121 | $this->assertEquals($content[0], readdir($wrapped)); 122 | $this->assertEquals($content[1], readdir($wrapped)); 123 | $this->assertEquals($content[2], readdir($wrapped)); 124 | rewinddir($wrapped); 125 | $this->assertEquals($content[0], readdir($wrapped)); 126 | } 127 | 128 | public function testUrlStat() { 129 | $interceptor = $this->createDummyInterceptor(); 130 | Stream::setInterceptor($interceptor); 131 | $expected = stat(__FILE__); 132 | $interceptor->wrap(); 133 | $result = stat(__FILE__); 134 | $interceptor->unwrap(); 135 | $this->assertEquals($expected, $result); 136 | } 137 | 138 | public function testMKDir() { 139 | $interceptor = $this->createDummyInterceptor(); 140 | Stream::setInterceptor($interceptor); 141 | $file = $this->tempNam(); 142 | unlink($file); 143 | $interceptor->wrap(); 144 | mkdir($file); 145 | $interceptor->unwrap(); 146 | $this->assertTrue(is_dir($file)); 147 | rmdir($file); 148 | } 149 | 150 | public function testRMDir() { 151 | $interceptor = $this->createDummyInterceptor(); 152 | Stream::setInterceptor($interceptor); 153 | $file = $this->tempNam(); 154 | unlink($file); 155 | mkdir($file); 156 | $interceptor->wrap(); 157 | rmdir($file); 158 | $interceptor->unwrap(); 159 | $this->assertFalse(is_dir($file)); 160 | } 161 | 162 | public function testRename() { 163 | $interceptor = $this->createDummyInterceptor(); 164 | Stream::setInterceptor($interceptor); 165 | $file1 = $this->tempNam(); 166 | $file2 = $this->tempNam(); 167 | unlink($file2); 168 | $interceptor->wrap(); 169 | rename($file1, $file2); 170 | $interceptor->unwrap(); 171 | $this->assertFalse(is_file($file1)); 172 | $this->assertTrue(is_file($file2)); 173 | } 174 | 175 | public function testUnlink() { 176 | $interceptor = $this->createDummyInterceptor(); 177 | Stream::setInterceptor($interceptor); 178 | $file = $this->tempNam(); 179 | $interceptor->wrap(); 180 | unlink($file); 181 | $interceptor->unwrap(); 182 | $this->assertFalse(is_file($file)); 183 | } 184 | 185 | public function testTouch() { 186 | $interceptor = $this->createDummyInterceptor(); 187 | Stream::setInterceptor($interceptor); 188 | $file = $this->tempNam(); 189 | $interceptor->wrap(); 190 | $this->assertTrue(touch($file)); 191 | $interceptor->unwrap(); 192 | } 193 | 194 | public function testChmod() { 195 | $interceptor = $this->createDummyInterceptor(); 196 | Stream::setInterceptor($interceptor); 197 | $file = $this->tempNam(); 198 | $interceptor->wrap(); 199 | $this->assertTrue(chmod($file, 0700)); 200 | $interceptor->unwrap(); 201 | } 202 | 203 | private function createDummyInterceptor(): Interceptor { 204 | return new Interceptor(function(string $path) { 205 | return null; 206 | }); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Stream.php: -------------------------------------------------------------------------------- 1 | unwrap(); 46 | try { 47 | $result = $callback(self::$defaultInterceptor); 48 | } finally { 49 | self::$defaultInterceptor->wrap(); 50 | } 51 | return $result; 52 | } 53 | 54 | /** 55 | * Determine file which called stream_open() based on backtrace. 56 | */ 57 | private function getCallingFile(array $backtrace): ?string { 58 | foreach ($backtrace as $call) { 59 | if (isset($call['file'])) { 60 | return $call['file']; 61 | } 62 | } 63 | return null; 64 | } 65 | 66 | /** 67 | * Check if the path is relative to the file that included it. 68 | */ 69 | private function fixPath(string $path, array $backtrace): string { 70 | if ($path[0] === '/') { 71 | return $path; 72 | } 73 | $callerDir = dirname($this->getCallingFile($backtrace)); 74 | $pathFromCallerContext = $callerDir . '/' . $path; 75 | if (file_exists($pathFromCallerContext)) { 76 | return $pathFromCallerContext; 77 | } else { 78 | return $path; 79 | } 80 | } 81 | 82 | /** 83 | * For phar:// streams the realpath() operation is not supported, so manually 84 | * resolve ./ and ../ segments, so that filtering code doesn't have to deal 85 | * with it. 86 | * 87 | * Returns null if the file does not exist. 88 | */ 89 | private function realpath(string $path): ?string { 90 | if (($realPath = realpath($path)) !== false) { 91 | return $realPath; 92 | } 93 | 94 | // Implementation based on https://github.com/UnionOfRAD/lithium/blob/master/core/Libraries.php. 95 | if (!preg_match('%^phar://(.+\.phar(?:\.gz)?)(.+)%', $path, $pathComponents)) { 96 | return null; 97 | } 98 | list(, $relativePath, $pharPath) = $pathComponents; 99 | 100 | $pharPath = implode('/', array_reduce(explode('/', $pharPath), function ($parts, $value) { 101 | if ($value === '..') { 102 | array_pop($parts); 103 | } elseif ($value !== '.') { 104 | $parts[] = $value; 105 | } 106 | return $parts; 107 | })); 108 | 109 | if (($resolvedPath = realpath($relativePath)) !== false) { 110 | if (file_exists($realPath = "phar://{$resolvedPath}{$pharPath}")) { 111 | return $realPath; 112 | } 113 | } 114 | return null; 115 | } 116 | 117 | public function stream_open($path, $mode, $options) { 118 | $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); 119 | return $this->runUnwrapped(function (Interceptor $interceptor) use ($path, $mode, $options, $backtrace) { 120 | $path = $this->fixPath($path, $backtrace); 121 | 122 | $including = (bool)($options & self::STREAM_OPEN_FOR_INCLUDE); 123 | if ($including) { 124 | $realPath = $this->realpath($path); 125 | if ($realPath !== null) { 126 | $this->resource = $interceptor->intercept($realPath); 127 | if ($this->resource !== null) { 128 | return true; 129 | } 130 | } 131 | } 132 | 133 | if (isset($this->context)) { 134 | $this->resource = fopen($path, $mode, $options, $this->context); 135 | } else { 136 | $this->resource = fopen($path, $mode, $options); 137 | } 138 | return $this->resource !== false; 139 | }); 140 | } 141 | 142 | public function stream_close() { 143 | return fclose($this->resource); 144 | } 145 | 146 | public function stream_eof() { 147 | return feof($this->resource); 148 | } 149 | 150 | public function stream_flush() { 151 | return fflush($this->resource); 152 | } 153 | 154 | public function stream_read($count) { 155 | return fread($this->resource, $count); 156 | } 157 | 158 | public function stream_seek($offset, $whence = SEEK_SET) { 159 | return fseek($this->resource, $offset, $whence) === 0; 160 | } 161 | 162 | public function stream_stat() { 163 | return fstat($this->resource); 164 | } 165 | 166 | public function stream_tell() { 167 | return ftell($this->resource); 168 | } 169 | 170 | public function url_stat($path, $flags) { 171 | return $this->runUnwrapped(function () use ($path, $flags) { 172 | if ($flags & STREAM_URL_STAT_QUIET) { 173 | set_error_handler(function () { 174 | return false; 175 | }); 176 | } 177 | $result = stat($path); 178 | if ($flags & STREAM_URL_STAT_QUIET) { 179 | restore_error_handler(); 180 | } 181 | return $result; 182 | }); 183 | } 184 | 185 | public function dir_closedir() { 186 | closedir($this->resource); 187 | return true; 188 | } 189 | 190 | public function dir_opendir($path) { 191 | return $this->runUnwrapped(function () use ($path) { 192 | if (isset($this->context)) { 193 | $this->resource = opendir($path, $this->context); 194 | } else { 195 | $this->resource = opendir($path); 196 | } 197 | return $this->resource !== false; 198 | }); 199 | } 200 | 201 | public function dir_readdir() { 202 | return readdir($this->resource); 203 | } 204 | 205 | public function dir_rewinddir() { 206 | rewinddir($this->resource); 207 | return true; 208 | } 209 | 210 | public function mkdir($path, $mode, $options) { 211 | return $this->runUnwrapped(function () use ($path, $mode, $options) { 212 | return mkdir($path, $mode, $options, $this->context); 213 | }); 214 | } 215 | 216 | public function rename($pathFrom, $pathTo) { 217 | return $this->runUnwrapped(function () use ($pathFrom, $pathTo) { 218 | return rename($pathFrom, $pathTo, $this->context); 219 | }); 220 | } 221 | 222 | public function rmdir($path) { 223 | return $this->runUnwrapped(function () use ($path) { 224 | return rmdir($path, $this->context); 225 | }); 226 | } 227 | 228 | public function stream_cast() { 229 | return $this->resource; 230 | } 231 | 232 | public function stream_lock($operation) { 233 | return flock($this->resource, $operation); 234 | } 235 | 236 | public function stream_set_option($option, $arg1, $arg2) { 237 | switch ($option) { 238 | case STREAM_OPTION_BLOCKING: 239 | return stream_set_blocking($this->resource, $arg1); 240 | case STREAM_OPTION_READ_TIMEOUT: 241 | return stream_set_timeout($this->resource, $arg1, $arg2); 242 | case STREAM_OPTION_WRITE_BUFFER: 243 | return stream_set_write_buffer($this->resource, $arg1); 244 | case STREAM_OPTION_READ_BUFFER: 245 | return stream_set_read_buffer($this->resource, $arg1); 246 | default: 247 | throw new \InvalidArgumentException(); 248 | } 249 | } 250 | 251 | public function stream_write($data) { 252 | return fwrite($this->resource, $data); 253 | } 254 | 255 | public function unlink($path) { 256 | return $this->runUnwrapped(function () use ($path) { 257 | return unlink($path, $this->context); 258 | }); 259 | } 260 | 261 | public function stream_metadata($path, $option, $value) { 262 | return $this->runUnwrapped(function () use ($path, $option, $value) { 263 | switch ($option) { 264 | case STREAM_META_TOUCH: 265 | if (empty($value)) { 266 | $result = touch($path); 267 | } else { 268 | $result = touch($path, $value[0], $value[1]); 269 | } 270 | break; 271 | case STREAM_META_OWNER_NAME: 272 | case STREAM_META_OWNER: 273 | $result = chown($path, $value); 274 | break; 275 | case STREAM_META_GROUP_NAME: 276 | case STREAM_META_GROUP: 277 | $result = chgrp($path, $value); 278 | break; 279 | case STREAM_META_ACCESS: 280 | $result = chmod($path, $value); 281 | break; 282 | default: 283 | throw new \InvalidArgumentException(); 284 | } 285 | return $result; 286 | }); 287 | } 288 | 289 | public function stream_truncate($new_size) { 290 | return ftruncate($this->resource, $new_size); 291 | } 292 | } 293 | --------------------------------------------------------------------------------