diff --git a/core/lib/Drupal/Core/Cache/CacheBackendInterface.php b/core/lib/Drupal/Core/Cache/CacheBackendInterface.php index 87b0a1b..b73f377 100644 --- a/core/lib/Drupal/Core/Cache/CacheBackendInterface.php +++ b/core/lib/Drupal/Core/Cache/CacheBackendInterface.php @@ -120,6 +120,7 @@ public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = array * ), * ); * @endcode + * Backend implementations must keep the order of items. */ public function setMultiple(array $items); diff --git a/core/lib/Drupal/Core/PhpStorage/CacheStorage.php b/core/lib/Drupal/Core/PhpStorage/CacheStorage.php new file mode 100644 index 0000000..ccd38dc --- /dev/null +++ b/core/lib/Drupal/Core/PhpStorage/CacheStorage.php @@ -0,0 +1,198 @@ +configuration = $configuration; + } + + /** + * {@inheritdoc} + */ + public function exists($name) { + $key = $this->getKeyFromFilename($name); + $cids = [$key, "$key:mtime"]; + $this->cacheBackend()->getMultiple($cids); + return !$cids; + } + + /** + * {@inheritdoc} + */ + public function load($name) { + $key = $this->getKeyFromFilename($name); + $cached = $this->cacheBackend()->get("$key:mtime"); + if (!$cached) { + return FALSE; + } + // Load the data from cache. + // Hook in the fake phar wrapper. Opcode included in PHP 5.5 hardwires file + // and phar as the only two stream wrappers which can be opcode cached. + // The file protocol is used to read local files and will be triggered + // multiple times by the classloader as the container class is loaded. + // So for better performance use the phar protocol. + stream_wrapper_unregister('phar'); + stream_wrapper_register('phar', 'Drupal\Core\StreamWrapper\StreamWrapperForCacheStorage'); + StreamWrapperForCacheStorage::init($this, $cached->data); + $return = (include "phar://$name") !== FALSE; + // Restore the system wrapper. + stream_wrapper_restore('phar'); + return $return; + } + + /** + * @param $name + * @return bool|resource + */ + public function open($name) { + $key = $this->getKeyFromFilename(substr($name, 7)); + if (!$cached = $this->cacheBackend()->get($key)) { + return FALSE; + } + // Copy it into a file in memory. + if (!$handle = fopen('php://memory', 'rwb')) { + return FALSE; + } + if (fwrite($handle, $cached->data) === FALSE || fseek($handle, 0) === -1) { + fclose($handle); + return FALSE; + } + return $handle; + } + + /** + * {@inheritdoc} + */ + public function save($name, $code) { + $key = $this->getKeyFromFilename($name); + // We do not need a real mtime, we just need a timestamp that changes when + // the code changes. + $hash = hash('sha256', $code); + $mtime = hexdec(substr($hash, 0, 7)); + $this->cacheBackend()->setMultiple([ + $key => ['data' => $code], + "$key:mtime" => ['data' => $mtime], + ]); + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function writeable() { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function delete($name) { + $return = $this->exists($name); + $key = $this->getKeyFromFilename($name); + // Delete nonetheless because between exists and delete the entry might've + // been written. + $this->cacheBackend()->delete($key); + return $return; + } + + /** + * {@inheritdoc} + */ + public function deleteAll() { + $this->cacheBackend()->deleteAll(); + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getFullPath($name) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function listAll() { + // Only PhpBackEnd::invalidateAll() uses this method and that's not + // compatible anyways since it relies on getFullPath(). + throw new \BadMethodCallException('CacheStorage::listall() is not implemented.'); + } + + /** + * @return \Drupal\Core\Cache\CacheBackendInterface + */ + protected function cacheBackend() { + if (!isset($this->cacheBackend)) { + if (isset($this->configuration['cache_backend_factory'])) { + $this->cacheBackend = call_user_func($this->configuration['cache_backend_factory'], $this->configuration); + } + else { + $this->cacheBackend = static::getDatabaseBackend($this->configuration); + } + } + return $this->cacheBackend; + } + + /** + * Construct a database cache backend. + */ + protected static function getDatabaseBackend($configuration) { + $connection = Database::getConnection(); + return new DatabaseBackend($connection, new DatabaseCacheTagsChecksum($connection), 'php_' . $configuration['bin']); + } + + /** + * Return a secret key based on the filename. + * + * By using a secret key, a SQL injection does not lead immediately to + * arbitrary PHP inclusion. + * + * @param string $filename + * The filename. + * + * @return string + * The secret hash. + */ + protected function getKeyFromFilename($filename) { + return hash_hmac('sha256', $filename, $this->configuration['secret']); + } + +} diff --git a/core/lib/Drupal/Core/StreamWrapper/StreamWrapperForCacheStorage.php b/core/lib/Drupal/Core/StreamWrapper/StreamWrapperForCacheStorage.php new file mode 100644 index 0000000..ba8a743 --- /dev/null +++ b/core/lib/Drupal/Core/StreamWrapper/StreamWrapperForCacheStorage.php @@ -0,0 +1,113 @@ +open($path); + return (bool) static::$handle; + } + + public function stream_close() { + return fclose(static::$handle); + } + + public function stream_eof() { + return feof(static::$handle); + } + + public function stream_read($count) { + return fread(static::$handle, $count); + } + + public function stream_flush() { + // This is called on every file close even if there is nothing to flush + // and we do not write anything so we do not actually need to flush + // anything. + return TRUE; + } + + public function stream_stat() { + // When the file is not yet opcode cached, the mtime is read through + // stream_stat() during file compile. The stat() results are not dependent + // on the position in the file and we know which file we are working on so + // using $handle is not necessary and just calling url_stat() to return the + // fake mtime is both correct and necessary. + return static::url_stat(); + } + + public function url_stat() { + $return = [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 0, + 'atime' => 0, + 'mtime' => static::$mtime, + 'ctime' => 0, + 'blksize' => -1, + 'blocks' => -1, + ]; + return $return + array_values($return); + } + +} diff --git a/core/tests/Drupal/Tests/Component/PhpStorage/PhpStorageTestBase.php b/core/tests/Drupal/Tests/Component/PhpStorage/PhpStorageTestBase.php index 53492d8..5493f02 100644 --- a/core/tests/Drupal/Tests/Component/PhpStorage/PhpStorageTestBase.php +++ b/core/tests/Drupal/Tests/Component/PhpStorage/PhpStorageTestBase.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\Component\PhpStorage; +use Drupal\Component\PhpStorage\PhpStorageInterface; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; @@ -48,6 +49,7 @@ public function assertCRUD($php) { $this->assertTrue($success, 'Saved php file'); $php->load($name); $this->assertTrue($GLOBALS[$random], 'File saved correctly with correct value'); + $this->additionalAssertCRUD($php, $name); // If the file was successfully loaded, it must also exist, but ensure the // exists() method returns that correctly. @@ -62,4 +64,16 @@ public function assertCRUD($php) { $this->assertFalse($php->delete($name), 'Delete fails on missing file'); } + /** + * Additional asserts to be run. + * + * @param \Drupal\Component\PhpStorage\PhpStorageInterface $php + * The PHP storage object. + * @param string $name + * The name of an object. It should exist in the storage. + */ + protected function additionalAssertCRUD(PhpStorageInterface $php, $name) { + + } + } diff --git a/core/tests/Drupal/Tests/Core/PhpStorage/CacheStorageTest.php b/core/tests/Drupal/Tests/Core/PhpStorage/CacheStorageTest.php new file mode 100644 index 0000000..d7a36c8 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/PhpStorage/CacheStorageTest.php @@ -0,0 +1,95 @@ +randomMachineName(); + $storage = new CacheStorage([ + 'secret' => $secret, + 'cache_backend_factory' => function (array $configuration) { + return $this->getBackend($configuration['bin']); + }, + 'bin' => 'test' + ]); + $this->assertCRUD($storage); + } + + /** + * @param $bin + * @return \PHPUnit_Framework_MockObject_MockObject|\Drupal\Core\Cache\CacheBackendInterface + */ + public function getBackend($bin) { + $cache_backend = $this->getMock('Drupal\Core\Cache\CacheBackendInterface'); + // This is the save() call. + $cache_backend->expects($this->once()) + ->method('setMultiple') + ->willReturnCallback(function ($data) { + $this->cache = $data; + }); + // mtime wil be retrieved on both loads. If opcache.enable_cli is on then + // the file itself is loaded only once. + $opcache_cli_enabed = ini_get('opcache.enable_cli'); + $cache_backend->expects($this->exactly(4 - $opcache_cli_enabed)) + ->method('get') + ->willReturnCallback(function ($cid) { + return (object) $this->cache[$cid]; + }); + // Two direct exists() calls and two exists() calls from delete. + $cache_backend->expects($this->exactly(4)) + ->method('getMultiple') + ->willReturnCallback(function (&$cids) { + $return = array_intersect_key($this->cache, array_flip($cids)); + $cids = array_diff_key($cids, array_keys($this->cache)); + return $return; + }); + // Two delete() cals. + $cache_backend->expects($this->exactly(2)) + ->method('delete') + ->willReturnCallback(function ($cid) { + unset($this->cache[$cid]); + }); + // Nothing else happens. + $cache_backend->expects($this->exactly(11 - $opcache_cli_enabed)) + ->method($this->anything()); + return $cache_backend; + } + + /** + * {@inheritdoc} + */ + protected function additionalAssertCRUD(PhpStorageInterface $php, $name) { + $php->load($name); + } + +}