From 8dcde4707296aacb279d3f6f5eb58537efadb128 Mon Sep 17 00:00:00 2001 From: Ne-Lexa Date: Wed, 22 Jan 2020 12:49:45 +0300 Subject: [PATCH] Added a new option for extracting unix symlinks. Added new parameter to get the list of extracted files. --- src/Constants/ZipOptions.php | 41 +++++++++++++++-- src/Util/FilesUtil.php | 32 ++++--------- src/ZipFile.php | 65 ++++++++++++++++---------- src/ZipFileInterface.php | 13 ++++-- tests/SymlinkTest.php | 88 ++++++++++++++++++++++++++++++++++++ tests/ZipFileTest.php | 27 +++++++---- 6 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 tests/SymlinkTest.php diff --git a/src/Constants/ZipOptions.php b/src/Constants/ZipOptions.php index 25c88a7..c5d9671 100644 --- a/src/Constants/ZipOptions.php +++ b/src/Constants/ZipOptions.php @@ -2,6 +2,9 @@ namespace PhpZip\Constants; +use PhpZip\IO\ZipReader; +use PhpZip\ZipFile; + /** * Interface ZipOptions. */ @@ -10,20 +13,50 @@ interface ZipOptions /** * Boolean option for store just file names (skip directory names). * - * @var string + * @see ZipFile::addFromFinder() */ const STORE_ONLY_FILES = 'only_files'; - /** @var string */ + /** + * Uses the specified compression method. + * + * @see ZipFile::addFromFinder() + * @see ZipFile::addSplFile() + */ const COMPRESSION_METHOD = 'compression_method'; - /** @var string */ + /** + * Set the specified record modification time. + * The value can be {@see \DateTimeInterface}, integer timestamp + * or a string of any format. + * + * @see ZipFile::addFromFinder() + * @see ZipFile::addSplFile() + */ const MODIFIED_TIME = 'mtime'; /** - * @var string + * Specifies the encoding of the record name for cases when the UTF-8 + * usage flag is not set. * + * The most commonly used encodings are compiled into the constants + * of the {@see DosCodePage} class. + * + * @see ZipFile::openFile() + * @see ZipFile::openFromString() + * @see ZipFile::openFromStream() + * @see ZipReader::getDefaultOptions() * @see DosCodePage::getCodePages() */ const CHARSET = 'charset'; + + /** + * Allows ({@see true}) or denies ({@see false}) unpacking unix symlinks. + * + * This is a potentially dangerous operation for uncontrolled zip files. + * By default is ({@see false}). + * + * @see https://josipfranjkovic.blogspot.com/2014/12/reading-local-files-from-facebooks.html + */ + const EXTRACT_SYMLINKS = 'extract_symlinks'; } diff --git a/src/Util/FilesUtil.php b/src/Util/FilesUtil.php index 6b016e8..c29c471 100644 --- a/src/Util/FilesUtil.php +++ b/src/Util/FilesUtil.php @@ -43,9 +43,10 @@ final class FilesUtil \RecursiveIteratorIterator::CHILD_FIRST ); + /** @var \SplFileInfo $fileInfo */ foreach ($files as $fileInfo) { $function = ($fileInfo->isDir() ? 'rmdir' : 'unlink'); - $function($fileInfo->getRealPath()); + $function($fileInfo->getPathname()); } rmdir($dir); } @@ -303,36 +304,19 @@ final class FilesUtil } /** - * @param string $linkPath * @param string $target + * @param string $path + * @param bool $allowSymlink * * @return bool */ - public static function symlink($target, $linkPath) + public static function symlink($target, $path, $allowSymlink) { - if (\DIRECTORY_SEPARATOR === '\\') { - $linkPath = str_replace('/', '\\', $linkPath); - $target = str_replace('/', '\\', $target); - $abs = null; - - if (!self::isAbsolutePath($target)) { - $abs = realpath(\dirname($linkPath) . \DIRECTORY_SEPARATOR . $target); - - if (\is_string($abs)) { - $target = $abs; - } - } + if (\DIRECTORY_SEPARATOR === '\\' || !$allowSymlink) { + return file_put_contents($path, $target) !== false; } - if (!symlink($target, $linkPath)) { - if (\DIRECTORY_SEPARATOR === '\\' && is_file($target)) { - return copy($target, $linkPath); - } - - return false; - } - - return true; + return symlink($target, $path); } /** diff --git a/src/ZipFile.php b/src/ZipFile.php index 6bbee69..427c6aa 100644 --- a/src/ZipFile.php +++ b/src/ZipFile.php @@ -6,6 +6,7 @@ namespace PhpZip; +use PhpZip\Constants\UnixStat; use PhpZip\Constants\ZipCompressionLevel; use PhpZip\Constants\ZipCompressionMethod; use PhpZip\Constants\ZipEncryptionMethod; @@ -374,15 +375,20 @@ class ZipFile implements ZipFileInterface * * Extract the complete archive or the given files to the specified destination. * - * @param string $destDir location where to extract the files - * @param array|string|null $entries The entries to extract. It accepts either - * a single entry name or an array of names. + * @param string $destDir location where to extract the files + * @param array|string|null $entries entries to extract + * @param array $options extract options + * @param array $extractedEntries if the extractedEntries argument + * is present, then the specified + * array will be filled with + * information about the + * extracted entries * * @throws ZipException * * @return ZipFile */ - public function extractTo($destDir, $entries = null) + public function extractTo($destDir, $entries = null, array $options = [], &$extractedEntries = []) { if (!file_exists($destDir)) { throw new ZipException(sprintf('Destination %s not found', $destDir)); @@ -396,7 +402,14 @@ class ZipFile implements ZipFileInterface throw new ZipException('Destination is not writable directory'); } - $extractedEntries = []; + if ($extractedEntries === null) { + $extractedEntries = []; + } + + $defaultOptions = [ + ZipOptions::EXTRACT_SYMLINKS => false, + ]; + $options += $defaultOptions; $zipEntries = $this->zipContainer->getEntries(); @@ -497,9 +510,8 @@ class ZipFile implements ZipFileInterface unlink($file); throw $e; - } finally { - fclose($handle); } + fclose($handle); if ($unixMode === 0) { $unixMode = 0644; @@ -514,8 +526,10 @@ class ZipFile implements ZipFileInterface } } + $allowSymlink = (bool) $options[ZipOptions::EXTRACT_SYMLINKS]; + foreach ($symlinks as $linkPath => $target) { - if (!FilesUtil::symlink($target, $linkPath)) { + if (!FilesUtil::symlink($target, $linkPath, $allowSymlink)) { unset($extractedEntries[$linkPath]); } } @@ -526,7 +540,7 @@ class ZipFile implements ZipFileInterface touch($dir, $lastMod); } -// ksort($extractedEntries); + ksort($extractedEntries); return $this; } @@ -663,9 +677,24 @@ class ZipFile implements ZipFileInterface $entryName = $file->isDir() ? rtrim($entryName, '/\\') . '/' : $entryName; $zipEntry = new ZipEntry($entryName); - $zipData = null; + $zipEntry->setCreatedOS(ZipPlatform::OS_UNIX); + $zipEntry->setExtractedOS(ZipPlatform::OS_UNIX); - if ($file->isFile()) { + $zipData = null; + $filePerms = $file->getPerms(); + + if ($file->isLink()) { + $linkTarget = $file->getLinkTarget(); + $lengthLinkTarget = \strlen($linkTarget); + + $zipEntry->setCompressionMethod(ZipCompressionMethod::STORED); + $zipEntry->setUncompressedSize($lengthLinkTarget); + $zipEntry->setCompressedSize($lengthLinkTarget); + $zipEntry->setCrc(crc32($linkTarget)); + $filePerms |= UnixStat::UNX_IFLNK; + + $zipData = new ZipNewData($zipEntry, $linkTarget); + } elseif ($file->isFile()) { if (isset($options[ZipOptions::COMPRESSION_METHOD])) { $compressionMethod = $options[ZipOptions::COMPRESSION_METHOD]; } elseif ($file->getSize() < 512) { @@ -685,21 +714,9 @@ class ZipFile implements ZipFileInterface $zipEntry->setUncompressedSize(0); $zipEntry->setCompressedSize(0); $zipEntry->setCrc(0); - } elseif ($file->isLink()) { - $linkTarget = $file->getLinkTarget(); - $lengthLinkTarget = \strlen($linkTarget); - - $zipEntry->setCompressionMethod(ZipCompressionMethod::STORED); - $zipEntry->setUncompressedSize($lengthLinkTarget); - $zipEntry->setCompressedSize($lengthLinkTarget); - $zipEntry->setCrc(crc32($linkTarget)); - - $zipData = new ZipNewData($zipEntry, $linkTarget); } - $zipEntry->setCreatedOS(ZipPlatform::OS_UNIX); - $zipEntry->setExtractedOS(ZipPlatform::OS_UNIX); - $zipEntry->setUnixMode($file->getPerms()); + $zipEntry->setUnixMode($filePerms); $timestamp = null; diff --git a/src/ZipFileInterface.php b/src/ZipFileInterface.php index 1ba1850..07108d1 100644 --- a/src/ZipFileInterface.php +++ b/src/ZipFileInterface.php @@ -292,15 +292,20 @@ interface ZipFileInterface extends \Countable, \ArrayAccess, \Iterator * * Extract the complete archive or the given files to the specified destination. * - * @param string $destDir location where to extract the files - * @param array|string|null $entries The entries to extract. It accepts either - * a single entry name or an array of names. + * @param string $destDir location where to extract the files + * @param array|string|null $entries entries to extract + * @param array $options extract options + * @param array $extractedEntries if the extractedEntries argument + * is present, then the specified + * array will be filled with + * information about the + * extracted entries * * @throws ZipException * * @return ZipFile */ - public function extractTo($destDir, $entries = null); + public function extractTo($destDir, $entries = null, array $options = [], &$extractedEntries = []); /** * Add entry from the string. diff --git a/tests/SymlinkTest.php b/tests/SymlinkTest.php new file mode 100644 index 0000000..2731607 --- /dev/null +++ b/tests/SymlinkTest.php @@ -0,0 +1,88 @@ +outputDirname)) { + self::assertTrue(mkdir($this->outputDirname, 0755, true)); + } + + $contentsFile = random_bytes(100); + $filePath = $this->outputDirname . '/file.bin'; + $symlinkPath = $this->outputDirname . '/symlink.bin'; + $symlinkTarget = basename($filePath); + self::assertNotFalse(file_put_contents($filePath, $contentsFile)); + self::assertTrue(symlink($symlinkTarget, $symlinkPath)); + + $finder = (new Finder())->in($this->outputDirname); + $zipFile = new ZipFile(); + $zipFile->addFromFinder($finder); + $zipFile->saveAsFile($this->outputFilename); + $zipFile->close(); + + self::assertCorrectZipArchive($this->outputFilename); + + FilesUtil::removeDir($this->outputDirname); + self::assertFalse(is_dir($this->outputDirname)); + self::assertTrue(mkdir($this->outputDirname, 0755, true)); + + $zipFile->openFile($this->outputFilename); + $zipFile->extractTo($this->outputDirname, null, [ + ZipOptions::EXTRACT_SYMLINKS => $allowSymlink, + ]); + $zipFile->close(); + + $splFileInfo = new \SplFileInfo($symlinkPath); + + if ($allowSymlink) { + self::assertTrue($splFileInfo->isLink()); + self::assertSame($splFileInfo->getLinkTarget(), $symlinkTarget); + } else { + self::assertFalse($splFileInfo->isLink()); + self::assertStringEqualsFile($symlinkPath, $symlinkTarget); + } + } + + /** + * @return \Generator + */ + public function provideAllowSymlink() + { + yield 'allow' => [true]; + yield 'deny' => [false]; + } +} diff --git a/tests/ZipFileTest.php b/tests/ZipFileTest.php index a864fec..b51ed12 100644 --- a/tests/ZipFileTest.php +++ b/tests/ZipFileTest.php @@ -1009,16 +1009,16 @@ class ZipFileTest extends ZipTestCase 'test1.txt' => random_bytes(255), 'test2.txt' => random_bytes(255), 'test/test 2/test3.txt' => random_bytes(255), - 'test empty/dir' => null, + 'test empty/dir/' => null, ]; $zipFile = new ZipFile(); - foreach ($entries as $entryName => $value) { - if ($value === null) { + foreach ($entries as $entryName => $contents) { + if ($contents === null) { $zipFile->addEmptyDir($entryName); } else { - $zipFile->addFromString($entryName, $value); + $zipFile->addFromString($entryName, $contents); } } $zipFile->saveAsFile($this->outputFilename); @@ -1027,19 +1027,28 @@ class ZipFileTest extends ZipTestCase static::assertTrue(mkdir($this->outputDirname, 0755, true)); $zipFile->openFile($this->outputFilename); - $zipFile->extractTo($this->outputDirname); + $zipFile->extractTo($this->outputDirname, null, [], $extractedEntries); - foreach ($entries as $entryName => $value) { + foreach ($entries as $entryName => $contents) { $fullExtractedFilename = $this->outputDirname . \DIRECTORY_SEPARATOR . $entryName; - if ($value === null) { + static::assertTrue( + isset($extractedEntries[$fullExtractedFilename]), + 'No extract info for ' . $fullExtractedFilename + ); + + if ($contents === null) { static::assertTrue(is_dir($fullExtractedFilename)); static::assertTrue(FilesUtil::isEmptyDir($fullExtractedFilename)); } else { static::assertTrue(is_file($fullExtractedFilename)); $contents = file_get_contents($fullExtractedFilename); - static::assertSame($contents, $value); + static::assertSame($contents, $contents); } + + /** @var ZipEntry $entry */ + $entry = $extractedEntries[$fullExtractedFilename]; + static::assertSame($entry->getName(), $entryName); } $zipFile->close(); } @@ -2431,7 +2440,7 @@ class ZipFileTest extends ZipTestCase $zipFile->saveAsFile($this->outputFilename); $zipAfterBeforeWrite = $zipFile->getEntry('file 1'); - static::assertEquals($zipAfterBeforeWrite, $zipEntryBeforeWrite); + static::assertSame($zipAfterBeforeWrite, $zipEntryBeforeWrite); $zipFile->close(); }