Skip to content

Commit 8475002

Browse files
andreromfabpot
authored andcommitted
[Filesystem] Add feature to create hardlinks for files
1 parent 030abb2 commit 8475002

File tree

3 files changed

+250
-11
lines changed

3 files changed

+250
-11
lines changed

src/Symfony/Component/Filesystem/Filesystem.php

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -330,14 +330,57 @@ public function symlink($originDir, $targetDir, $copyOnWindows = false)
330330
}
331331

332332
if (!$ok && true !== @symlink($originDir, $targetDir)) {
333-
$report = error_get_last();
334-
if (is_array($report)) {
335-
if ('\\' === DIRECTORY_SEPARATOR && false !== strpos($report['message'], 'error code(1314)')) {
336-
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);
333+
$this->linkException($originDir, $targetDir, 'symbolic');
334+
}
335+
}
336+
337+
/**
338+
* Creates a hard link, or several hard links to a file.
339+
*
340+
* @param string $originFile The original file
341+
* @param string|string[] $targetFiles The target file(s)
342+
*
343+
* @throws FileNotFoundException When original file is missing or not a file
344+
* @throws IOException When link fails, including if link already exists
345+
*/
346+
public function hardlink($originFile, $targetFiles)
347+
{
348+
if (!$this->exists($originFile)) {
349+
throw new FileNotFoundException(null, 0, null, $originFile);
350+
}
351+
352+
if (!is_file($originFile)) {
353+
throw new FileNotFoundException(sprintf('Origin file "%s" is not a file', $originFile));
354+
}
355+
356+
foreach ($this->toIterator($targetFiles) as $targetFile) {
357+
if (is_file($targetFile)) {
358+
if (fileinode($originFile) === fileinode($targetFile)) {
359+
continue;
337360
}
361+
$this->remove($targetFile);
362+
}
363+
364+
if (true !== @link($originFile, $targetFile)) {
365+
$this->linkException($originFile, $targetFile, 'hard');
366+
}
367+
}
368+
}
369+
370+
/**
371+
* @param string $origin
372+
* @param string $target
373+
* @param string $linkType Name of the link type, typically 'symbolic' or 'hard'
374+
*/
375+
private function linkException($origin, $target, $linkType)
376+
{
377+
$report = error_get_last();
378+
if (is_array($report)) {
379+
if ('\\' === DIRECTORY_SEPARATOR && false !== strpos($report['message'], 'error code(1314)')) {
380+
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);
338381
}
339-
throw new IOException(sprintf('Failed to create symbolic link from "%s" to "%s".', $originDir, $targetDir), 0, null, $targetDir);
340382
}
383+
throw new IOException(sprintf('Failed to create %s link from "%s" to "%s".', $linkType, $origin, $target), 0, null, $target);
341384
}
342385

343386
/**

src/Symfony/Component/Filesystem/Tests/FilesystemTest.php

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,20 @@ public function testChownSymlink()
565565
$this->filesystem->chown($link, $this->getFileOwner($link));
566566
}
567567

568+
public function testChownLink()
569+
{
570+
$this->markAsSkippedIfLinkIsMissing();
571+
572+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
573+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
574+
575+
touch($file);
576+
577+
$this->filesystem->hardlink($file, $link);
578+
579+
$this->filesystem->chown($link, $this->getFileOwner($link));
580+
}
581+
568582
/**
569583
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
570584
*/
@@ -582,6 +596,23 @@ public function testChownSymlinkFails()
582596
$this->filesystem->chown($link, 'user'.time().mt_rand(1000, 9999));
583597
}
584598

599+
/**
600+
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
601+
*/
602+
public function testChownLinkFails()
603+
{
604+
$this->markAsSkippedIfLinkIsMissing();
605+
606+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
607+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
608+
609+
touch($file);
610+
611+
$this->filesystem->hardlink($file, $link);
612+
613+
$this->filesystem->chown($link, 'user'.time().mt_rand(1000, 9999));
614+
}
615+
585616
/**
586617
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
587618
*/
@@ -631,6 +662,20 @@ public function testChgrpSymlink()
631662
$this->filesystem->chgrp($link, $this->getFileGroup($link));
632663
}
633664

665+
public function testChgrpLink()
666+
{
667+
$this->markAsSkippedIfLinkIsMissing();
668+
669+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
670+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
671+
672+
touch($file);
673+
674+
$this->filesystem->hardlink($file, $link);
675+
676+
$this->filesystem->chgrp($link, $this->getFileGroup($link));
677+
}
678+
634679
/**
635680
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
636681
*/
@@ -648,6 +693,23 @@ public function testChgrpSymlinkFails()
648693
$this->filesystem->chgrp($link, 'user'.time().mt_rand(1000, 9999));
649694
}
650695

696+
/**
697+
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
698+
*/
699+
public function testChgrpLinkFails()
700+
{
701+
$this->markAsSkippedIfLinkIsMissing();
702+
703+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
704+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
705+
706+
touch($file);
707+
708+
$this->filesystem->hardlink($file, $link);
709+
710+
$this->filesystem->chgrp($link, 'user'.time().mt_rand(1000, 9999));
711+
}
712+
651713
/**
652714
* @expectedException \Symfony\Component\Filesystem\Exception\IOException
653715
*/
@@ -799,6 +861,103 @@ public function testSymlinkCreatesTargetDirectoryIfItDoesNotExist()
799861
$this->assertEquals($file, readlink($link2));
800862
}
801863

864+
public function testLink()
865+
{
866+
$this->markAsSkippedIfLinkIsMissing();
867+
868+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
869+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
870+
871+
touch($file);
872+
$this->filesystem->hardlink($file, $link);
873+
874+
$this->assertTrue(is_file($link));
875+
$this->assertEquals(fileinode($file), fileinode($link));
876+
}
877+
878+
/**
879+
* @depends testLink
880+
*/
881+
public function testRemoveLink()
882+
{
883+
$this->markAsSkippedIfLinkIsMissing();
884+
885+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
886+
887+
$this->filesystem->remove($link);
888+
889+
$this->assertTrue(!is_file($link));
890+
}
891+
892+
public function testLinkIsOverwrittenIfPointsToDifferentTarget()
893+
{
894+
$this->markAsSkippedIfLinkIsMissing();
895+
896+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
897+
$file2 = $this->workspace.DIRECTORY_SEPARATOR.'file2';
898+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
899+
900+
touch($file);
901+
touch($file2);
902+
link($file2, $link);
903+
904+
$this->filesystem->hardlink($file, $link);
905+
906+
$this->assertTrue(is_file($link));
907+
$this->assertEquals(fileinode($file), fileinode($link));
908+
}
909+
910+
public function testLinkIsNotOverwrittenIfAlreadyCreated()
911+
{
912+
$this->markAsSkippedIfLinkIsMissing();
913+
914+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
915+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
916+
917+
touch($file);
918+
link($file, $link);
919+
920+
$this->filesystem->hardlink($file, $link);
921+
922+
$this->assertTrue(is_file($link));
923+
$this->assertEquals(fileinode($file), fileinode($link));
924+
925+
}
926+
927+
public function testLinkWithSeveralTargets()
928+
{
929+
$this->markAsSkippedIfLinkIsMissing();
930+
931+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
932+
$link1 = $this->workspace.DIRECTORY_SEPARATOR.'link';
933+
$link2 = $this->workspace.DIRECTORY_SEPARATOR.'link2';
934+
935+
touch($file);
936+
937+
$this->filesystem->hardlink($file, array($link1,$link2));
938+
939+
$this->assertTrue(is_file($link1));
940+
$this->assertEquals(fileinode($file), fileinode($link1));
941+
$this->assertTrue(is_file($link2));
942+
$this->assertEquals(fileinode($file), fileinode($link2));
943+
}
944+
945+
public function testLinkWithSameTarget()
946+
{
947+
$this->markAsSkippedIfLinkIsMissing();
948+
949+
$file = $this->workspace.DIRECTORY_SEPARATOR.'file';
950+
$link = $this->workspace.DIRECTORY_SEPARATOR.'link';
951+
952+
touch($file);
953+
954+
// practically same as testLinkIsNotOverwrittenIfAlreadyCreated
955+
$this->filesystem->hardlink($file, array($link,$link));
956+
957+
$this->assertTrue(is_file($link));
958+
$this->assertEquals(fileinode($file), fileinode($link));
959+
}
960+
802961
/**
803962
* @dataProvider providePathsForMakePathRelative
804963
*/

src/Symfony/Component/Filesystem/Tests/FilesystemTestCase.php

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,42 @@ class FilesystemTestCase extends \PHPUnit_Framework_TestCase
2929
*/
3030
protected $workspace = null;
3131

32+
/**
33+
* @var null|bool Flag for hard links on Windows
34+
*/
35+
private static $linkOnWindows = null;
36+
37+
/**
38+
* @var null|bool Flag for symbolic links on Windows
39+
*/
3240
private static $symlinkOnWindows = null;
3341

3442
public static function setUpBeforeClass()
3543
{
36-
if ('\\' === DIRECTORY_SEPARATOR && null === self::$symlinkOnWindows) {
37-
$target = tempnam(sys_get_temp_dir(), 'sl');
38-
$link = sys_get_temp_dir().'/sl'.microtime(true).mt_rand();
39-
self::$symlinkOnWindows = @symlink($target, $link) && is_link($link);
40-
@unlink($link);
41-
unlink($target);
44+
if ('\\' === DIRECTORY_SEPARATOR) {
45+
self::$linkOnWindows = true;
46+
$originFile = tempnam(sys_get_temp_dir(), 'li');
47+
$targetFile = tempnam(sys_get_temp_dir(), 'li');
48+
if (true !== @link($originFile, $targetFile)) {
49+
$report = error_get_last();
50+
if (is_array($report) && false !== strpos($report['message'], 'error code(1314)')) {
51+
self::$linkOnWindows = false;
52+
}
53+
} else {
54+
@unlink($targetFile);
55+
}
56+
57+
self::$symlinkOnWindows = true;
58+
$originDir = tempnam(sys_get_temp_dir(), 'sl');
59+
$targetDir = tempnam(sys_get_temp_dir(), 'sl');
60+
if (true !== @symlink($originDir, $targetDir)) {
61+
$report = error_get_last();
62+
if (is_array($report) && false !== strpos($report['message'], 'error code(1314)')) {
63+
self::$symlinkOnWindows = false;
64+
}
65+
} else {
66+
@unlink($targetDir);
67+
}
4268
}
4369
}
4470

@@ -100,6 +126,17 @@ protected function getFileGroup($filepath)
100126
$this->markTestSkipped('Unable to retrieve file group name');
101127
}
102128

129+
protected function markAsSkippedIfLinkIsMissing()
130+
{
131+
if (!function_exists('link')) {
132+
$this->markTestSkipped('link is not supported');
133+
}
134+
135+
if ('\\' === DIRECTORY_SEPARATOR && false === self::$linkOnWindows) {
136+
$this->markTestSkipped('link requires "Create hard links" privilege on windows');
137+
}
138+
}
139+
103140
protected function markAsSkippedIfSymlinkIsMissing($relative = false)
104141
{
105142
if ('\\' === DIRECTORY_SEPARATOR && false === self::$symlinkOnWindows) {

0 commit comments

Comments
 (0)