diff --git a/src/Symfony/Component/Filesystem/CHANGELOG.md b/src/Symfony/Component/Filesystem/CHANGELOG.md index a4c0479f7d..aee6e804b0 100644 --- a/src/Symfony/Component/Filesystem/CHANGELOG.md +++ b/src/Symfony/Component/Filesystem/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +2.8.0 +----- + + * added tempnam() a stream aware version of PHP's native tempnam() + 2.6.0 ----- diff --git a/src/Symfony/Component/Filesystem/Filesystem.php b/src/Symfony/Component/Filesystem/Filesystem.php index 93a1f0de4c..b10a264118 100644 --- a/src/Symfony/Component/Filesystem/Filesystem.php +++ b/src/Symfony/Component/Filesystem/Filesystem.php @@ -452,6 +452,53 @@ class Filesystem ); } + /** + * Creates a temporary file with support for custom stream wrappers. + * + * @param string $dir The directory where the temporary filename will be created. + * @param string $prefix The prefix of the generated temporary filename. + * Note: Windows uses only the first three characters of prefix. + * + * @return string The new temporary filename (with path), or false on failure. + */ + public function tempnam($dir, $prefix) + { + $limit = 10; + list($scheme, $hierarchy) = $this->getSchemeAndHierarchy($dir); + + // If no scheme or scheme is "file" create temp file in local filesystem + if (null === $scheme || 'file' === $scheme) { + $tmpFile = tempnam($hierarchy, $prefix); + + // If tempnam failed or no scheme return the filename otherwise prepend the scheme + return false === $tmpFile || null === $scheme ? $tmpFile : $scheme.'://'.$tmpFile; + } + + // Loop until we create a valid temp file or have reached $limit attempts + for ($i = 0; $i < $limit; $i++) { + + // Create a unique filename + $tmpFile = $dir.'/'.$prefix.uniqid(mt_rand(), true); + + // Use fopen instead of file_exists as some streams do not support stat + // Use mode 'x' to atomically check existence and create to avoid a TOCTOU vulnerability + $handle = @fopen($tmpFile, 'x'); + + // If unsuccessful restart the loop + if (false === $handle) { + continue; + } + + // Close the file if it was successfully opened + @fclose($handle); + + return $tmpFile; + + } + + return false; + } + /** * Atomically dumps content into a file. * @@ -472,7 +519,7 @@ class Filesystem throw new IOException(sprintf('Unable to write to the "%s" directory.', $dir), 0, null, $dir); } - $tmpFile = tempnam($dir, basename($filename)); + $tmpFile = $this->tempnam($dir, basename($filename)); if (false === @file_put_contents($tmpFile, $content)) { throw new IOException(sprintf('Failed to write file "%s".', $filename), 0, null, $filename); @@ -501,4 +548,19 @@ class Filesystem return $files; } + + /** + * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> array(file, tmp)). + * + * @param string $filename The filename to be parsed. + * + * @return array The filename scheme and hierarchical part + */ + private function getSchemeAndHierarchy($filename) + { + $components = explode('://', $filename, 2); + + return count($components) >= 2 ? array($components[0], $components[1]) : array(null, $components[0]); + } + } diff --git a/src/Symfony/Component/Filesystem/README.md b/src/Symfony/Component/Filesystem/README.md index df09f93dce..466924f3b7 100644 --- a/src/Symfony/Component/Filesystem/README.md +++ b/src/Symfony/Component/Filesystem/README.md @@ -30,11 +30,15 @@ $filesystem->rename($origin, $target); $filesystem->symlink($originDir, $targetDir, $copyOnWindows = false); +$filesystem->tempnam($dir, $prefix); + $filesystem->makePathRelative($endPath, $startPath); $filesystem->mirror($originDir, $targetDir, \Traversable $iterator = null, $options = array()); $filesystem->isAbsolutePath($file); + +$filesystem->dumpFile($file, $content); ``` Resources diff --git a/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php b/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php index 45254c3c71..7eceacc273 100644 --- a/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php +++ b/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Filesystem\Tests; +use Phar; /** * Test class for Filesystem. */ @@ -946,6 +947,116 @@ class FilesystemTest extends FilesystemTestCase ); } + public function testTempnam() + { + $dirname = $this->workspace; + + $filename = $this->filesystem->tempnam($dirname, 'foo'); + + $this->assertNotFalse($filename); + $this->assertFileExists($filename); + } + + public function testTempnamWithFileScheme() + { + $scheme = 'file://'; + $dirname = $scheme.$this->workspace; + + $filename = $this->filesystem->tempnam($dirname, 'foo'); + + $this->assertNotFalse($filename); + $this->assertStringStartsWith($scheme, $filename); + $this->assertFileExists($filename); + } + + public function testTempnamWithMockScheme() + { + // We avoid autoloading via ClassLoader as stream_wrapper_register creates the object + if (!@include __DIR__.DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR.'MockStream'.DIRECTORY_SEPARATOR.'MockStream.php') { + $this->markTestSkipped('Unable to load mock:// stream.'); + } + + stream_wrapper_register('mock', 'MockStream\MockStream'); + + $scheme = 'mock://'; + $dirname = $scheme.$this->workspace; + + $filename = $this->filesystem->tempnam($dirname, 'foo'); + + $this->assertNotFalse($filename); + $this->assertStringStartsWith($scheme, $filename); + $this->assertFileExists($filename); + } + + public function testTempnamWithZlibSchemeFails() + { + $scheme = 'compress.zlib://'; + $dirname = $scheme.$this->workspace; + + $filename = $this->filesystem->tempnam($dirname, 'bar'); + + // The compress.zlib:// stream does not support mode x: creates the file, errors "failed to open stream: operation failed" and returns false + $this->assertFalse($filename); + } + + public function testTempnamWithPHPTempSchemeFails() + { + $scheme = 'php://temp'; + $dirname = $scheme; + + $filename = $this->filesystem->tempnam($dirname, 'bar'); + + $this->assertNotFalse($filename); + $this->assertStringStartsWith($scheme, $filename); + + // The php://temp stream deletes the file after close + $this->assertFileNotExists($filename); + } + + public function testTempnamWithPharSchemeFails() + { + // Skip test if Phar disabled phar.readonly must be 0 in php.ini + if (!Phar::canWrite()) { + $this->markTestSkipped('This test cannot run when phar.readonly is 1.'); + } + + $scheme = 'phar://'; + $dirname = $scheme.$this->workspace; + $pharname = 'foo.phar'; + + $p = new Phar($this->workspace.'/'.$pharname, 0, $pharname); + $filename = $this->filesystem->tempnam($dirname, $pharname.'/bar'); + + // The phar:// stream does not support mode x: fails to create file, errors "failed to open stream: phar error: "$filename" is not a file in phar "$pharname"" and returns false + $this->assertFalse($filename); + } + + public function testTempnamWithHTTPSchemeFails() + { + $scheme = 'http://'; + $dirname = $scheme.$this->workspace; + + $filename = $this->filesystem->tempnam($dirname, 'bar'); + + // The http:// scheme is read-only + $this->assertFalse($filename); + } + + public function testTempnamOnUnwritableFallsBackToSysTmp() + { + $scheme = 'file://'; + $dirname = $scheme.$this->workspace.DIRECTORY_SEPARATOR.'does_not_exist'; + + $filename = $this->filesystem->tempnam($dirname, 'bar'); + + $this->assertNotFalse($filename); + $this->assertStringStartsWith(rtrim($scheme.sys_get_temp_dir(), DIRECTORY_SEPARATOR), $filename); + $this->assertFileExists($filename); + + // Tear down + unlink($filename); + } + public function testDumpFile() { $filename = $this->workspace.DIRECTORY_SEPARATOR.'foo'.DIRECTORY_SEPARATOR.'baz.txt'; @@ -1000,6 +1111,29 @@ class FilesystemTest extends FilesystemTestCase $this->assertSame('bar', file_get_contents($filename)); } + public function testDumpFileWithFileScheme() + { + $scheme = 'file://'; + $filename = $scheme.$this->workspace.DIRECTORY_SEPARATOR.'foo'.DIRECTORY_SEPARATOR.'baz.txt'; + + $this->filesystem->dumpFile($filename, 'bar', null); + + $this->assertFileExists($filename); + $this->assertSame('bar', file_get_contents($filename)); + } + + public function testDumpFileWithZlibScheme() + { + $scheme = 'compress.zlib://'; + $filename = $this->workspace.DIRECTORY_SEPARATOR.'foo'.DIRECTORY_SEPARATOR.'baz.txt'; + + $this->filesystem->dumpFile($filename, 'bar', null); + + // Zlib stat uses file:// wrapper so remove scheme + $this->assertFileExists(str_replace($scheme, '', $filename)); + $this->assertSame('bar', file_get_contents($filename)); + } + public function testCopyShouldKeepExecutionPermission() { $this->markAsSkippedIfChmodIsMissing(); diff --git a/src/Symfony/Component/Filesystem/Tests/Fixtures/MockStream/MockStream.php b/src/Symfony/Component/Filesystem/Tests/Fixtures/MockStream/MockStream.php new file mode 100644 index 0000000000..ac1a840d23 --- /dev/null +++ b/src/Symfony/Component/Filesystem/Tests/Fixtures/MockStream/MockStream.php @@ -0,0 +1,226 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * This class is based on VariableStream from the PHP Manual, which is licenced + * under Creative Commons Attribution 3.0 Licence copyright (c) the PHP + * Documentation Group + * + * @url http://php.net/manual/en/stream.streamwrapper.example-1.php + * @url http://php.net/license/ + * @url http://creativecommons.org/licenses/by/3.0/legalcode + */ +namespace MockStream; + +/** + * Mock stream class to be used with stream_wrapper_register. + * + * stream_wrapper_register('mock', 'MockStream\MockStream') + */ +class MockStream { + private $str_overloaded; + private $content; + private $position; + private $atime; + private $mtime; + private $ctime; + private $path; + + /** + * Opens file or URL. + * + * @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 + * @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 + */ + public function stream_open($path, $mode, $options, &$opened_path) { + // Is mbstring.func_overload applied to string functions (bit 2 set) + $this->str_overloaded = (bool) (ini_get('mbstring.func_overload') & (1 << 2)); + $this->path = $path; + $this->content = ''; + $this->position = 0; + $this->atime = 0; + $this->mtime = 0; + + return true; + } + + /** + * Read from stream. + * + * @param int $count How many bytes of data from the current position should be returned + * + * @return string The data + */ + public function stream_read($count) { + $ret = $this->substr($this->varname, $this->position, $count); + $this->position += $this->strlen($ret); + $this->atime = time(); + + return $ret; + } + + /** + * Write to stream. + * + * @param string $data Data to write to the stream + * + * @return int Number of bytes that were successfully stored, or 0 if none could be stored + */ + public function stream_write($data) { + $left = $this->substr($this->content, 0, $this->position); + $right = $this->substr($this->content, $this->position + $this->strlen($data)); + $this->content = $left.$data.$right; + $this->position += $this->strlen($data); + $this->mtime = time(); + $this->ctime = time(); + + return $this->strlen($data); + } + + /** + * Retrieve the current position of a stream. + * + * @return int The current position of the stream + */ + public function stream_tell() { + return $this->position; + } + + /** + * Tests for end-of-file on a file pointer. + * + * @return bool Return 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 $this->position >= $this->strlen($this->content); + } + + /** + * Seeks to specific location in a stream. + * + * @param string $offset The stream offset to seek to + * @param int $whence Set position based on value + * + * @return bool Return true if the position was updated, false otherwise + */ + public function stream_seek($offset, $whence) { + switch ($whence) { + case SEEK_SET: + if ($offset < $this->strlen($this->content) && 0 <= $offset) { + $this->position = $offset; + + return true; + } + break; + + case SEEK_CUR: + if (0 <= $offset) { + $this->position += $offset; + + return true; + } + break; + + case SEEK_END: + if (0 <= $this->strlen($this->content) + $offset) { + $this->position = $this->strlen($this->content) + $offset; + + return true; + } + break; + } + + return false; + } + + /** + * Change stream options, only touch is supported. + * + * @param string $path The file path or URL to set metadata + * @param array $option + * @param array $value Additional arguments for the option + * + * @return bool Return true on success or fale on failure or if option is not implemented + */ + public function stream_metadata($path, $option, $value) { + if (STREAM_META_TOUCH === $option) { + $now = array_key_exists(0, $value) ? $value[0] : time(); + $this->atime = array_key_exists(1, $value) ? $value[1] : $now; + $this->mtime = $now; + $this->ctime = $now; + + return true; + } + + return false; + } + + /** + * Retrieve information about a stream. + * + * @return array Stream stats + */ + public function stream_stat() { + return array( + 'dev' => 0, + 'ino' => 0, + 'mode' => 33188, // 100644 + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => $this->strlen($this->content), + 'atime' => $this->atime, + 'mtime' => $this->mtime, + 'ctime' => $this->ctime, + 'blksize' => 4096, + 'blocks' => 8, + ); + } + + /** + * Retrieve information about a url, added as called by PHP's builtin functions. + * + * @param string $path The file path or URL to stat + * @param array $flags Holds additional flags set by the streams API + * + * @return array File stats + */ + public function url_stat($path, $flags) { + return $this->stream_stat(); + } + + /** + * Returns the number of bytes of the given string even when strlen is overloaded to mb_strlen. + * + * @param string $string The string being measured for bytes + * + * @return int The number of bytes in the string on success, and 0 if the string is empty + */ + protected function strlen($string) { + return function_exists('mb_strlen') && $this->str_overloaded ? mb_strlen($string, '8bit') : strlen($string); + } + + /** + * Returns the portion of string specified by the start and length parameters even when substr is overloaded to mb_substr. + * + * @param string $string The input string which must be one character or longer + * @param start $int Starting position in bytes + * @param length $int Length in bytes which if omitted or NULL is passed, extracts all bytes to the end of the string + * + * @return string + */ + protected function substr($string, $start, $length = null) { + return function_exists('mb_substr') && $this->str_overloaded ? mb_substr($string, $start, $length, '8bit') : substr($string, $start, $length); + } + +}