[FEATURE] Introduce a stream wrapper to overlay file paths 11/29011/10
authorPhilipp Gampe <philipp.gampe@typo3.org>
Tue, 1 Apr 2014 01:56:18 +0000 (03:56 +0200)
committerChristian Kuhn <lolli@schwarzbu.ch>
Thu, 29 Jan 2015 13:40:01 +0000 (14:40 +0100)
Implement a stream wrapper for the file:// protocol that can intercept
any call to the filesystem.
Transparently rewrite registered paths such that they can be replaced
by vfs:// stream wrappers.

Resolves: #57477
Releases: master
Change-Id: I3bd2e12f58d618883aa962b1d090b9c172c89be1
Reviewed-on: http://review.typo3.org/29011
Reviewed-by: Mathias Schreiber <mathias.schreiber@wmdb.de>
Tested-by: Mathias Schreiber <mathias.schreiber@wmdb.de>
Reviewed-by: Stefan Neufeind <typo3.neufeind@speedpartner.de>
Tested-by: Stefan Neufeind <typo3.neufeind@speedpartner.de>
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
typo3/sysext/core/Tests/FileStreamWrapper.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/FileStreamWrapperTest.php [new file with mode: 0644]

diff --git a/typo3/sysext/core/Tests/FileStreamWrapper.php b/typo3/sysext/core/Tests/FileStreamWrapper.php
new file mode 100644 (file)
index 0000000..3f1fe02
--- /dev/null
@@ -0,0 +1,567 @@
+<?php
+namespace TYPO3\CMS\Core\Tests;
+
+/**
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+/**
+ * Stream wrapper for the file:// protocol
+ *
+ * Implementation details:
+ * Due to the nature of PHP, it is not possible to switch to the default handler
+ * other then restoring the default handler for file:// and registering it again
+ * around each call.
+ * It is important that the default handler is restored to allow autoloading (including)
+ * of files during the test run.
+ * For each method allowed to pass paths, the passed path is checked against the
+ * the list of paths to overlay and rewritten if needed.
+ *
+ * = Usage =
+ * <code title="Add use statements">
+ * use org\bovigo\vfs\vfsStream;
+ * use org\bovigo\vfs\visitor\vfsStreamStructureVisitor;
+ * </code>
+ *
+ * <code title="Usage in test">
+ * $root = \org\bovigo\vfs\vfsStream::setup('root');
+ * $subfolder = \org\bovigo\vfs\vfsStream::newDirectory('fileadmin');
+ * $root->addChild($subfolder);
+ * // Load fixture files and folders from disk
+ * \org\bovigo\vfs\vfsStream::copyFromFileSystem(__DIR__ . '/Fixture/Files', $subfolder, 1024*1024);
+ * FileStreamWrapper::init(PATH_site);
+ * FileStreamWrapper::registerOverlayPath('fileadmin', 'vfs://root/fileadmin');
+ *
+ * // Use file functions as usual
+ * mkdir(PATH_site . 'fileadmin/test/');
+ * $file = PATH_site . 'fileadmin/test/Foo.bar';
+ * file_put_contents($file, 'Baz');
+ * $content = file_get_contents($file);
+ * $this->assertSame('Baz', $content);
+ *
+ * $this->assertEqual(**array(file system structure as array**), vfsStream::inspect(new vfsStreamStructureVisitor())->getStructure());
+ *
+ * FileStreamWrapper::destroy();
+ * </code>
+ *
+ * @see http://www.php.net/manual/en/class.streamwrapper.php
+ */
+class FileStreamWrapper {
+
+       /**
+        * @var resource
+        */
+       protected $dirHandle = NULL;
+
+       /**
+        * @var resource
+        */
+       protected $fileHandle = NULL;
+
+       /**
+        * Switch whether class has already been registered as stream wrapper or not
+        *
+        * @type bool
+        */
+       protected static $registered = FALSE;
+
+       /**
+        * Array of paths to overlay
+        *
+        * @var array
+        */
+       protected static $overlayPaths = array();
+
+       /**
+        * The first part of each (absolute) path that shall be ignored
+        *
+        * @var string
+        */
+       protected static $rootPath = '';
+
+       /**
+        * Initialize the stream wrapper with a root path and register itself
+        *
+        * @param $rootPath
+        * @return void
+        */
+       public static function init($rootPath) {
+               self::$rootPath = rtrim(str_replace('\\', '/', $rootPath), '/') . '/';
+               self::register();
+       }
+
+       /**
+        * Unregister the stream wrapper and reset all static members to their default values
+        * @return void
+        */
+       public static function destroy() {
+               self::$overlayPaths = array();
+               self::$rootPath = '';
+               if (self::$registered) {
+                       self::restore();
+               }
+       }
+
+       /**
+        * Register a path relative to the root path (set in init) to be overlaid
+        *
+        * @param string $overlay Relative path to the root folder
+        * @param string $replace The path that should replace the overlay path
+        * @param bool $createFolder TRUE of the folder should be created (mkdir)
+        * @return void
+        */
+       public static function registerOverlayPath($overlay, $replace, $createFolder = TRUE) {
+               $overlay = trim(str_replace('\\', '/', $overlay), '/') . '/';
+               $replace = rtrim(str_replace('\\', '/', $replace), '/') . '/';
+               self::$overlayPaths[$overlay] = $replace;
+               if ($createFolder) {
+                       mkdir($replace);
+               }
+       }
+
+       /**
+        * Checks and overlays a path
+        *
+        * @param string $path The path to check
+        * @return string The potentially overlaid path
+        */
+       protected static function overlayPath($path) {
+               $path = str_replace('\\', '/', $path);
+               $hasOverlay = FALSE;
+               if (strpos($path, self::$rootPath) !== 0) {
+                       // Path is not below root path, ignore it
+                       return $path;
+               }
+
+               $newPath = substr($path, strlen(self::$rootPath));
+               foreach (self::$overlayPaths as $overlay => $replace) {
+                       if (strpos($newPath, $overlay) === 0) {
+                               $newPath = $replace . substr($newPath, strlen($overlay));
+                               $hasOverlay = TRUE;
+                               break;
+                       }
+               }
+               return $hasOverlay ? $newPath : $path;
+       }
+
+       /**
+        * Method to register the stream wrapper
+        *
+        * If the stream is already registered the method returns silently. If there
+        * is already another stream wrapper registered for the scheme used by
+        * file:// scheme a \BadFunctionCallException will be thrown.
+        *
+        * @throws \BadFunctionCallException
+        * @return void
+        */
+       protected static function register() {
+               if (self::$registered) {
+                       return;
+               }
+
+               if (@stream_wrapper_unregister('file') === FALSE) {
+                       throw new \BadFunctionCallException('Cannot unregister file:// stream wrapper.', 1396340331);
+               }
+               if (@stream_wrapper_register('file', __CLASS__) === FALSE) {
+                       throw new \BadFunctionCallException('A handler has already been registered for the file:// scheme.', 1396340332);
+               }
+
+               self::$registered = TRUE;
+       }
+
+       /**
+        * Restore the file handler
+        *
+        * @return void
+        */
+       protected static function restore() {
+               if (!self::$registered) {
+                       return;
+               }
+               if (@stream_wrapper_restore('file') === FALSE) {
+                       throw new \BadFunctionCallException('Cannot restore the default file:// stream handler.', 1396340333);
+               }
+               self::$registered = FALSE;
+       }
+
+
+       /*
+        * The following list of functions is implemented as of
+        * @see http://www.php.net/manual/en/streamwrapper.dir-closedir.php
+        */
+
+       /**
+        * Close the directory
+        *
+        * @return bool
+        */
+       public function dir_closedir() {
+               if ($this->dirHandle === NULL) {
+                       return FALSE;
+               } else {
+                       self::restore();
+                       closedir($this->dirHandle);
+                       self::register();
+                       $this->dirHandle = NULL;
+                       return TRUE;
+               }
+       }
+
+       /**
+        * Opens a directory for reading
+        *
+        * @param string $path
+        * @param int $options
+        * @return bool
+        */
+       public function dir_opendir($path, $options = 0) {
+               if ($this->dirHandle !== NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $path = self::overlayPath($path);
+               $this->dirHandle = opendir($path);
+               self::register();
+               return $this->dirHandle !== FALSE;
+       }
+
+       /**
+        * Read a single filename of a directory
+        *
+        * @return string|bool
+        */
+       public function dir_readdir() {
+               if ($this->dirHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $success = readdir($this->dirHandle);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Reset directory name pointer
+        *
+        * @return bool
+        */
+       public function dir_rewinddir() {
+               if ($this->dirHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               rewinddir($this->dirHandle);
+               self::register();
+               return TRUE;
+       }
+
+       /**
+        * Create a directory
+        *
+        * @param string $path
+        * @param int $mode
+        * @param int $options
+        * @return bool
+        */
+       public function mkdir($path, $mode, $options = 0) {
+               self::restore();
+               $path = self::overlayPath($path);
+               $success = mkdir($path, $mode, (bool)($options & STREAM_MKDIR_RECURSIVE));
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Rename a file
+        *
+        * @param string $pathFrom
+        * @param string $pathTo
+        * @return bool
+        */
+       public function rename($pathFrom, $pathTo) {
+               self::restore();
+               $pathFrom = self::overlayPath($pathFrom);
+               $pathTo = self::overlayPath($pathTo);
+               $success = rename($pathFrom, $pathTo);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Remove a directory
+        *
+        * @param string $path
+        * @return bool
+        */
+       public function rmdir($path) {
+               self::restore();
+               $path = self::overlayPath($path);
+               $success = rmdir($path);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Close a file
+        *
+        */
+       public function stream_close() {
+               self::restore();
+               if ($this->fileHandle !== NULL) {
+                       fclose($this->fileHandle);
+                       $this->fileHandle = NULL;
+               }
+               self::register();
+       }
+
+       /**
+        * Test for end-of-file on a file pointer
+        *
+        * @return bool
+        */
+       public function stream_eof() {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $success = feof($this->fileHandle);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Flush the output
+        *
+        * @return bool
+        */
+       public function stream_flush() {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $success = fflush($this->fileHandle);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Advisory file locking
+        *
+        * @param int $operation
+        * @return bool
+        */
+       public function stream_lock($operation) {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $success = flock($this->fileHandle, $operation);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Change file options
+        *
+        * @param string $path
+        * @param int $options
+        * @param mixed $value
+        * @return bool
+        */
+       public function stream_metadata($path, $options, $value) {
+               self::restore();
+               $path = self::overlayPath($path);
+               switch ($options) {
+                       case STREAM_META_TOUCH:
+                               $success = touch($path, $value[0], $value[1]);
+                               break;
+                       case STREAM_META_OWNER_NAME:
+                               // Fall through
+                       case STREAM_META_OWNER:
+                               $success = chown($path, $value);
+                               break;
+                       case STREAM_META_GROUP_NAME:
+                               // Fall through
+                       case STREAM_META_GROUP:
+                               $success = chgrp($path, $value);
+                               break;
+                       case STREAM_META_ACCESS:
+                               $success = chmod($path, $value);
+                               break;
+                       default:
+                               $success = FALSE;
+               }
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Open a file
+        *
+        * @param string $path
+        * @param string $mode
+        * @param int $options
+        * @param string &$opened_path
+        * @return bool
+        */
+       public function stream_open($path, $mode, $options, &$opened_path) {
+               if ($this->fileHandle !== NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $path = self::overlayPath($path);
+               $this->fileHandle = fopen($path, $mode, (bool)($options & STREAM_USE_PATH));
+               self::register();
+               return $this->fileHandle !== FALSE;
+       }
+
+       /**
+        * Read from a file
+        *
+        * @param int $length
+        * @return string
+        */
+       public function stream_read($length) {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $content = fread($this->fileHandle, $length);
+               self::register();
+               return $content;
+       }
+
+       /**
+        * Seek to specific location in a stream
+        *
+        * @param int $offset
+        * @param int $whence = SEEK_SET
+        * @return bool
+        */
+       public function stream_seek($offset, $whence = SEEK_SET) {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $success = fseek($this->fileHandle, $offset, $whence);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Change stream options (not implemented)
+        *
+        * @param int $option
+        * @param int $arg1
+        * @param int $arg2
+        * @return bool
+        */
+       public function stream_set_option($option, $arg1, $arg2) {
+               return FALSE;
+       }
+
+       /**
+        * Retrieve information about a file resource
+        *
+        * @return array
+        */
+       public function stream_stat() {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $stats = fstat($this->fileHandle);
+               self::register();
+               return $stats;
+       }
+
+       /**
+        * Retrieve the current position of a stream
+        *
+        * @return int
+        */
+       public function stream_tell() {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $position = ftell($this->fileHandle);
+               self::register();
+               return $position;
+       }
+
+       /**
+        * Truncates a file to the given size
+        *
+        * @param int $size Truncate to this size
+        * @return bool
+        */
+       public function stream_truncate($size) {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $success = ftruncate($this->fileHandle, $size);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Write to stream
+        *
+        * @param string $data
+        * @return int
+        */
+       public function stream_write($data) {
+               if ($this->fileHandle === NULL) {
+                       return FALSE;
+               }
+               self::restore();
+               $length = fwrite($this->fileHandle, $data);
+               self::register();
+               return $length;
+       }
+
+       /**
+        * Unlink a file
+        *
+        * @param string $path
+        * @return bool
+        */
+       public function unlink($path) {
+               self::restore();
+               $path = self::overlayPath($path);
+               $success = unlink($path);
+               self::register();
+               return $success;
+       }
+
+       /**
+        * Retrieve information about a file
+        *
+        * @param string $path
+        * @param int $flags
+        * @return array
+        */
+       public function url_stat($path, $flags) {
+               self::restore();
+               $path = self::overlayPath($path);
+               if ($flags & STREAM_URL_STAT_LINK) {
+                       $information = @lstat($path);
+               } else {
+                       $information = @stat($path);
+               }
+               self::register();
+               return $information;
+       }
+
+}
diff --git a/typo3/sysext/core/Tests/Unit/FileStreamWrapperTest.php b/typo3/sysext/core/Tests/Unit/FileStreamWrapperTest.php
new file mode 100644 (file)
index 0000000..29cc693
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+namespace TYPO3\CMS\Core\Tests\Unit;
+
+/**
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use org\bovigo\vfs\vfsStream;
+use org\bovigo\vfs\visitor\vfsStreamStructureVisitor;
+use TYPO3\CMS\Core\Tests\FileStreamWrapper;
+
+/**
+ * Test case for \TYPO3\CMS\Core\Tests\Unit\FileStreamWrapper
+ */
+class FileStreamWrapperTest extends \TYPO3\CMS\Core\Tests\UnitTestCase {
+
+       /**
+        * @test
+        */
+       public function pathsAreOverlaidAndFinalDirectoryStructureCanBeQueried() {
+               $root = vfsStream::setup('root');
+               $subfolder = vfsStream::newDirectory('fileadmin');
+               $root->addChild($subfolder);
+               // Load fixture files and folders from disk
+               vfsStream::copyFromFileSystem(__DIR__ . '/TypoScript/Fixtures', $subfolder, 1024*1024);
+               FileStreamWrapper::init(PATH_site);
+               FileStreamWrapper::registerOverlayPath('fileadmin', 'vfs://root/fileadmin', FALSE);
+
+               // Use file functions as normal
+               mkdir(PATH_site . 'fileadmin/test/');
+               $file = PATH_site . 'fileadmin/test/Foo.bar';
+               file_put_contents($file, 'Baz');
+               $content = file_get_contents($file);
+               $this->assertSame('Baz', $content);
+
+               $expectedFileSystem = array(
+                       'root' => array(
+                               'fileadmin' => array(
+                                       'ext_typoscript_setup.txt' => 'test.Core.TypoScript = 1',
+                                       'test' => array('Foo.bar' => 'Baz'),
+                               ),
+                       ),
+               );
+               $this->assertEquals($expectedFileSystem, vfsStream::inspect(new vfsStreamStructureVisitor())->getStructure());
+
+               FileStreamWrapper::destroy();
+       }
+
+       /**
+        * @test
+        */
+       public function windowsPathsCanBeProcessed() {
+               $cRoot = 'C:\\Windows\\Root\\Path\\';
+               $root = vfsStream::setup('root');
+               FileStreamWrapper::init($cRoot);
+               FileStreamWrapper::registerOverlayPath('fileadmin', 'vfs://root/fileadmin');
+
+               touch($cRoot . 'fileadmin\\someFile.txt');
+               $expectedFileStructure = array(
+                       'root' => array(
+                               'fileadmin' => array('someFile.txt' => NULL),
+                       ),
+               );
+
+               $this->assertEquals($expectedFileStructure, vfsStream::inspect(new vfsStreamStructureVisitor())->getStructure());
+               FileStreamWrapper::destroy();
+       }
+
+       /**
+        * @test
+        */
+       public function symlinksCanBeCreated() {
+               $this->markTestSkipped('symlink() is not routed through the stream wrapper as of PHP 5.5, therefore we cannot test it');
+               /*
+                * symlink() is not routed through the stream wrapper as of PHP 5.5,
+                *  therefore we cannot test it.
+                */
+               vfsStream::setup('root');
+               FileStreamWrapper::init(PATH_site);
+               FileStreamWrapper::registerOverlayPath('fileadmin', 'vfs://root/fileadmin');
+
+               $path = PATH_site . 'fileadmin/';
+               touch($path . 'file1.txt');
+               symlink($path . 'file1.txt', $path . 'file2.txt');
+
+               $this->assertTrue(is_link($path . 'file2.txt'));
+       }
+}
\ No newline at end of file