feature #15458 [Filesystem] Add feature to create hardlinks for files (andrerom)

This PR was submitted for the 2.8 branch but it was merged into the 3.2-dev branch instead (closes #15458).

Discussion
----------

[Filesystem] Add feature to create hardlinks for files

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets |
| License       | MIT
| Doc PR        |

Todo:
- [x] Tests
- [ ] Doc
- [x] Getting someone to test on Windows as exception might differ from symlink functionality

## Why
Symlinks are good for directories, but for files hardlinks are sometime a better match when needing to represent same file in several locations.

One use case for this feature is for multi tagging of Http Cache when running against FileSystem. Multi tagging is used in FoSHttpCache and supported when running Varnish, but not with Symfony HTTPCache, yet.. With hardlinks combined with meta information containing tags within the file, lookup and reverse lookup is thus easily possible.

## What
Introduces method `hardlink()` with similar behavior as `symlink`, difference being:
- Allowing several targets to be provided to match use case in 'why'
- Like `symlink` removes existing target if not the same, but uses `fileinode` to compare target & source

Commits
-------

8475002 [Filesystem] Add feature to create hardlinks for files
This commit is contained in:
Fabien Potencier 2016-06-13 22:18:54 +02:00
commit 31678c9118
3 changed files with 252 additions and 13 deletions

View File

@ -330,16 +330,59 @@ class Filesystem
}
if (!$ok && true !== @symlink($originDir, $targetDir)) {
$report = error_get_last();
if (is_array($report)) {
if ('\\' === DIRECTORY_SEPARATOR && false !== strpos($report['message'], 'error code(1314)')) {
throw new IOException('Unable to create symlink due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', 0, null, $targetDir);
}
}
throw new IOException(sprintf('Failed to create symbolic link from "%s" to "%s".', $originDir, $targetDir), 0, null, $targetDir);
$this->linkException($originDir, $targetDir, 'symbolic');
}
}
/**
* Creates a hard link, or several hard links to a file.
*
* @param string $originFile The original file
* @param string|string[] $targetFiles The target file(s)
*
* @throws FileNotFoundException When original file is missing or not a file
* @throws IOException When link fails, including if link already exists
*/
public function hardlink($originFile, $targetFiles)
{
if (!$this->exists($originFile)) {
throw new FileNotFoundException(null, 0, null, $originFile);
}
if (!is_file($originFile)) {
throw new FileNotFoundException(sprintf('Origin file "%s" is not a file', $originFile));
}
foreach ($this->toIterator($targetFiles) as $targetFile) {
if (is_file($targetFile)) {
if (fileinode($originFile) === fileinode($targetFile)) {
continue;
}
$this->remove($targetFile);
}
if (true !== @link($originFile, $targetFile)) {
$this->linkException($originFile, $targetFile, 'hard');
}
}
}
/**
* @param string $origin
* @param string $target
* @param string $linkType Name of the link type, typically 'symbolic' or 'hard'
*/
private function linkException($origin, $target, $linkType)
{
$report = error_get_last();
if (is_array($report)) {
if ('\\' === DIRECTORY_SEPARATOR && false !== strpos($report['message'], 'error code(1314)')) {
throw new IOException(sprintf('Unable to create %s link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target);
}
}
throw new IOException(sprintf('Failed to create %s link from "%s" to "%s".', $linkType, $origin, $target), 0, null, $target);
}
/**
* Given an existing path, convert it to a path relative to a given starting path.
*

View File

@ -565,6 +565,20 @@ class FilesystemTest extends FilesystemTestCase
$this->filesystem->chown($link, $this->getFileOwner($link));
}
public function testChownLink()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
$this->filesystem->hardlink($file, $link);
$this->filesystem->chown($link, $this->getFileOwner($link));
}
/**
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
*/
@ -582,6 +596,23 @@ class FilesystemTest extends FilesystemTestCase
$this->filesystem->chown($link, 'user'.time().mt_rand(1000, 9999));
}
/**
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
*/
public function testChownLinkFails()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
$this->filesystem->hardlink($file, $link);
$this->filesystem->chown($link, 'user'.time().mt_rand(1000, 9999));
}
/**
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
*/
@ -631,6 +662,20 @@ class FilesystemTest extends FilesystemTestCase
$this->filesystem->chgrp($link, $this->getFileGroup($link));
}
public function testChgrpLink()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
$this->filesystem->hardlink($file, $link);
$this->filesystem->chgrp($link, $this->getFileGroup($link));
}
/**
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
*/
@ -648,6 +693,23 @@ class FilesystemTest extends FilesystemTestCase
$this->filesystem->chgrp($link, 'user'.time().mt_rand(1000, 9999));
}
/**
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
*/
public function testChgrpLinkFails()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
$this->filesystem->hardlink($file, $link);
$this->filesystem->chgrp($link, 'user'.time().mt_rand(1000, 9999));
}
/**
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
*/
@ -799,6 +861,103 @@ class FilesystemTest extends FilesystemTestCase
$this->assertEquals($file, readlink($link2));
}
public function testLink()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
$this->filesystem->hardlink($file, $link);
$this->assertTrue(is_file($link));
$this->assertEquals(fileinode($file), fileinode($link));
}
/**
* @depends testLink
*/
public function testRemoveLink()
{
$this->markAsSkippedIfLinkIsMissing();
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
$this->filesystem->remove($link);
$this->assertTrue(!is_file($link));
}
public function testLinkIsOverwrittenIfPointsToDifferentTarget()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$file2 = $this->workspace.DIRECTORY_SEPARATOR.'file2';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
touch($file2);
link($file2, $link);
$this->filesystem->hardlink($file, $link);
$this->assertTrue(is_file($link));
$this->assertEquals(fileinode($file), fileinode($link));
}
public function testLinkIsNotOverwrittenIfAlreadyCreated()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
link($file, $link);
$this->filesystem->hardlink($file, $link);
$this->assertTrue(is_file($link));
$this->assertEquals(fileinode($file), fileinode($link));
}
public function testLinkWithSeveralTargets()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link1 = $this->workspace.DIRECTORY_SEPARATOR.'link';
$link2 = $this->workspace.DIRECTORY_SEPARATOR.'link2';
touch($file);
$this->filesystem->hardlink($file, array($link1,$link2));
$this->assertTrue(is_file($link1));
$this->assertEquals(fileinode($file), fileinode($link1));
$this->assertTrue(is_file($link2));
$this->assertEquals(fileinode($file), fileinode($link2));
}
public function testLinkWithSameTarget()
{
$this->markAsSkippedIfLinkIsMissing();
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
touch($file);
// practically same as testLinkIsNotOverwrittenIfAlreadyCreated
$this->filesystem->hardlink($file, array($link,$link));
$this->assertTrue(is_file($link));
$this->assertEquals(fileinode($file), fileinode($link));
}
/**
* @dataProvider providePathsForMakePathRelative
*/

View File

@ -29,16 +29,42 @@ class FilesystemTestCase extends \PHPUnit_Framework_TestCase
*/
protected $workspace = null;
/**
* @var null|bool Flag for hard links on Windows
*/
private static $linkOnWindows = null;
/**
* @var null|bool Flag for symbolic links on Windows
*/
private static $symlinkOnWindows = null;
public static function setUpBeforeClass()
{
if ('\\' === DIRECTORY_SEPARATOR && null === self::$symlinkOnWindows) {
$target = tempnam(sys_get_temp_dir(), 'sl');
$link = sys_get_temp_dir().'/sl'.microtime(true).mt_rand();
self::$symlinkOnWindows = @symlink($target, $link) && is_link($link);
@unlink($link);
unlink($target);
if ('\\' === DIRECTORY_SEPARATOR) {
self::$linkOnWindows = true;
$originFile = tempnam(sys_get_temp_dir(), 'li');
$targetFile = tempnam(sys_get_temp_dir(), 'li');
if (true !== @link($originFile, $targetFile)) {
$report = error_get_last();
if (is_array($report) && false !== strpos($report['message'], 'error code(1314)')) {
self::$linkOnWindows = false;
}
} else {
@unlink($targetFile);
}
self::$symlinkOnWindows = true;
$originDir = tempnam(sys_get_temp_dir(), 'sl');
$targetDir = tempnam(sys_get_temp_dir(), 'sl');
if (true !== @symlink($originDir, $targetDir)) {
$report = error_get_last();
if (is_array($report) && false !== strpos($report['message'], 'error code(1314)')) {
self::$symlinkOnWindows = false;
}
} else {
@unlink($targetDir);
}
}
}
@ -100,6 +126,17 @@ class FilesystemTestCase extends \PHPUnit_Framework_TestCase
$this->markTestSkipped('Unable to retrieve file group name');
}
protected function markAsSkippedIfLinkIsMissing()
{
if (!function_exists('link')) {
$this->markTestSkipped('link is not supported');
}
if ('\\' === DIRECTORY_SEPARATOR && false === self::$linkOnWindows) {
$this->markTestSkipped('link requires "Create hard links" privilege on windows');
}
}
protected function markAsSkippedIfSymlinkIsMissing($relative = false)
{
if ('\\' === DIRECTORY_SEPARATOR && false === self::$symlinkOnWindows) {