Skip to content

Commit

Permalink
Add an extract command (#390)
Browse files Browse the repository at this point in the history
Closes #383
theofidry authored Jun 6, 2019
1 parent d134334 commit bd569e3
Showing 11 changed files with 491 additions and 70 deletions.
Binary file added fixtures/extract/compressed-phar.phar
Binary file not shown.
Empty file added fixtures/extract/invalid
Empty file.
Empty file added fixtures/extract/invalid.phar
Empty file.
Binary file added fixtures/extract/simple-phar
Binary file not shown.
Binary file added fixtures/extract/simple-phar.phar
Binary file not shown.
1 change: 1 addition & 0 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
@@ -87,6 +87,7 @@ protected function getDefaultCommands(): array
$commands[] = new Command\Diff();
$commands[] = new Command\Info();
$commands[] = new Command\Process();
$commands[] = new Command\Extract();
$commands[] = new Command\Validate();
$commands[] = new Command\Verify();
$commands[] = new Command\GenerateDockerFile();
74 changes: 4 additions & 70 deletions src/Console/Command/Compile.php
Original file line number Diff line number Diff line change
@@ -19,19 +19,18 @@
use function array_search;
use function array_shift;
use Assert\Assertion;
use Closure;
use function count;
use function decoct;
use function explode;
use function file_exists;
use function filesize;
use function function_exists;
use function get_class;
use Humbug\PhpScoper\Whitelist;
use function implode;
use function is_string;
use KevinGH\Box\Box;
use const KevinGH\Box\BOX_ALLOW_XDEBUG;
use function KevinGH\Box\bump_open_file_descriptor_limit;
use function KevinGH\Box\check_php_settings;
use KevinGH\Box\Compactor\Compactor;
use KevinGH\Box\Composer\ComposerConfiguration;
@@ -56,10 +55,6 @@
use function memory_get_usage;
use function microtime;
use const PHP_EOL;
use function posix_getrlimit;
use const POSIX_RLIMIT_INFINITY;
use const POSIX_RLIMIT_NOFILE;
use function posix_setrlimit;
use function putenv;
use RuntimeException;
use function sprintf;
@@ -214,9 +209,8 @@ private function createPhar(
IO $io,
bool $debug
): Box {
$box = Box::create(
$config->getTmpOutputPath()
);
$box = Box::create($config->getTmpOutputPath());

$box->startBuffering();

$this->registerReplacementValues($config, $box, $logger);
@@ -624,7 +618,7 @@ private function configureCompressionAlgorithm(
)
);

$restoreLimit = self::bumpOpenFileDescriptorLimit($box, $io);
$restoreLimit = bump_open_file_descriptor_limit($box, $io);

try {
$extension = $box->compress($algorithm);
@@ -647,66 +641,6 @@ private function configureCompressionAlgorithm(
}
}

/**
* Bumps the maximum number of open file descriptor if necessary.
*
* @return Closure callable to call to restore the original maximum number of open files descriptors
*/
private static function bumpOpenFileDescriptorLimit(Box $box, IO $io): Closure
{
$filesCount = count($box) + 128; // Add a little extra for good measure

if (false === function_exists('posix_getrlimit') || false === function_exists('posix_setrlimit')) {
$io->writeln(
'<info>[debug] Could not check the maximum number of open file descriptors: the functions "posix_getrlimit()" and '
.'"posix_setrlimit" could not be found.</info>',
OutputInterface::VERBOSITY_DEBUG
);

return static function (): void {};
}

$softLimit = posix_getrlimit()['soft openfiles'];
$hardLimit = posix_getrlimit()['hard openfiles'];

if ($softLimit >= $filesCount) {
return static function (): void {};
}

$io->writeln(
sprintf(
'<info>[debug] Increased the maximum number of open file descriptors from ("%s", "%s") to ("%s", "%s")'
.'</info>',
$softLimit,
$hardLimit,
$filesCount,
'unlimited'
),
OutputInterface::VERBOSITY_DEBUG
);

posix_setrlimit(
POSIX_RLIMIT_NOFILE,
$filesCount,
'unlimited' === $hardLimit ? POSIX_RLIMIT_INFINITY : $hardLimit
);

return static function () use ($io, $softLimit, $hardLimit): void {
if (function_exists('posix_setrlimit') && isset($softLimit, $hardLimit)) {
posix_setrlimit(
POSIX_RLIMIT_NOFILE,
$softLimit,
'unlimited' === $hardLimit ? POSIX_RLIMIT_INFINITY : $hardLimit
);

$io->writeln(
'<info>[debug] Restored the maximum number of open file descriptors</info>',
OutputInterface::VERBOSITY_DEBUG
);
}
};
}

private function signPhar(
Configuration $config,
Box $box,
126 changes: 126 additions & 0 deletions src/Console/Command/Extract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

/*
* This file is part of the box project.
*
* (c) Kevin Herrera <[email protected]>
* Théo Fidry <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace KevinGH\Box\Console\Command;

use KevinGH\Box\Box;
use function KevinGH\Box\bump_open_file_descriptor_limit;
use KevinGH\Box\Console\IO\IO;
use function KevinGH\Box\create_temporary_phar;
use function KevinGH\Box\FileSystem\dump_file;
use function KevinGH\Box\FileSystem\remove;
use PharFileInfo;
use function realpath;
use RuntimeException;
use function sprintf;
use Symfony\Component\Console\Exception\RuntimeException as ConsoleRuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Throwable;

/**
* @private
*/
final class Extract extends BaseCommand
{
private const PHAR_ARG = 'phar';
private const OUTPUT_ARG = 'output';

/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->setName('extract');
$this->setDescription(
'🚚 Extracts a given PHAR into a directory'
);
$this->addArgument(
self::PHAR_ARG,
InputArgument::REQUIRED,
'The PHAR file.'
);
$this->addArgument(
self::OUTPUT_ARG,
InputArgument::REQUIRED,
'The output directory'
);
}

/**
* {@inheritdoc}
*/
protected function executeCommand(IO $io): int
{
$input = $io->getInput();

$file = realpath($input->getArgument(self::PHAR_ARG));

if (false === $file) {
$io->error(
sprintf(
'The file "%s" could not be found.',
$input->getArgument(self::PHAR_ARG)
)
);

return 1;
}

$tmpFile = create_temporary_phar($file);

try {
$box = Box::create($tmpFile);
} catch (Throwable $throwable) {
if ($io->isDebug()) {
throw new ConsoleRuntimeException(
'The given file is not a valid PHAR',
0,
$throwable
);
}

$io->error('The given file is not a valid PHAR');

return 1;
}

$restoreLimit = bump_open_file_descriptor_limit($box, $io);

$outputDir = $input->getArgument(self::OUTPUT_ARG);

try {
remove($outputDir);

foreach ($box->getPhar() as $pharFile) {
/* @var PharFileInfo $pharFile */
dump_file(
$outputDir.'/'.$pharFile->getFilename(),
(string) $pharFile->getContent()
);
}
} catch (RuntimeException $exception) {
$io->error($exception->getMessage());

return 1;
} finally {
$restoreLimit();

remove($tmpFile);
}

$io->success('');

return 0;
}
}
64 changes: 64 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
@@ -25,20 +25,24 @@
use function defined;
use ErrorException;
use function floor;
use function function_exists;
use KevinGH\Box\Console\IO\IO;
use KevinGH\Box\Console\Php\PhpSettingsHandler;
use function KevinGH\Box\FileSystem\copy;
use function log;
use function number_format;
use PackageVersions\Versions;
use Phar;
use function posix_getrlimit;
use function posix_setrlimit;
use function random_bytes;
use function sprintf;
use function str_replace;
use function strlen;
use function strtolower;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Logger\ConsoleLogger;
use Symfony\Component\Console\Output\OutputInterface;

/**
* @private
@@ -264,3 +268,63 @@ static function (int $code, string $message, string $file = '', int $line = -1):
}
);
}

/**
* Bumps the maximum number of open file descriptor if necessary.
*
* @return Closure callable to call to restore the original maximum number of open files descriptors
*/
function bump_open_file_descriptor_limit(Box $box, IO $io): Closure
{
$filesCount = count($box) + 128; // Add a little extra for good measure

if (false === function_exists('posix_getrlimit') || false === function_exists('posix_setrlimit')) {
$io->writeln(
'<info>[debug] Could not check the maximum number of open file descriptors: the functions "posix_getrlimit()" and '
.'"posix_setrlimit" could not be found.</info>',
OutputInterface::VERBOSITY_DEBUG
);

return static function (): void {};
}

$softLimit = posix_getrlimit()['soft openfiles'];
$hardLimit = posix_getrlimit()['hard openfiles'];

if ($softLimit >= $filesCount) {
return static function (): void {};
}

$io->writeln(
sprintf(
'<info>[debug] Increased the maximum number of open file descriptors from ("%s", "%s") to ("%s", "%s")'
.'</info>',
$softLimit,
$hardLimit,
$filesCount,
'unlimited'
),
OutputInterface::VERBOSITY_DEBUG
);

posix_setrlimit(
POSIX_RLIMIT_NOFILE,
$filesCount,
'unlimited' === $hardLimit ? POSIX_RLIMIT_INFINITY : $hardLimit
);

return static function () use ($io, $softLimit, $hardLimit): void {
if (function_exists('posix_setrlimit') && isset($softLimit, $hardLimit)) {
posix_setrlimit(
POSIX_RLIMIT_NOFILE,
$softLimit,
'unlimited' === $hardLimit ? POSIX_RLIMIT_INFINITY : $hardLimit
);

$io->writeln(
'<info>[debug] Restored the maximum number of open file descriptors</info>',
OutputInterface::VERBOSITY_DEBUG
);
}
};
}
1 change: 1 addition & 0 deletions tests/Console/ApplicationTest.php
Original file line number Diff line number Diff line change
@@ -113,6 +113,7 @@ public function test_get_helper_menu(): void
compile 🔨 Compiles an application into a PHAR
diff 🕵 Displays the differences between all of the files in two PHARs
docker 🐳 Generates a Dockerfile for the given PHAR
extract 🚚 Extracts a given PHAR into a directory
help Displays help for a command
info 🔍 Displays information about the PHAR extension or file
list Lists commands
295 changes: 295 additions & 0 deletions tests/Console/Command/ExtractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
<?php

declare(strict_types=1);

/*
* This file is part of the box project.
*
* (c) Kevin Herrera <kevin@herrera.io>
* Théo Fidry <theo.fidry@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace KevinGH\Box\Console\Command;

use KevinGH\Box\Console\DisplayNormalizer;
use function KevinGH\Box\FileSystem\make_path_relative;
use KevinGH\Box\Test\CommandTestCase;
use Phar;
use function preg_replace;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use UnexpectedValueException;

/**
* @covers \KevinGH\Box\Console\Command\Extract
*
* @runTestsInSeparateProcesses This is necessary as instantiating a PHAR in memory may load/autoload some stuff which
* can create undesirable side-effects.
*/
class ExtractTest extends CommandTestCase
{
private const FIXTURES = __DIR__.'/../../../fixtures/extract';

/**
* {@inheritdoc}
*/
protected function getCommand(): Command
{
return new Extract();
}

public function test_it_can_extract_a_phar(): void
{
$pharPath = self::FIXTURES.'/simple-phar.phar';

$this->commandTester->execute(
[
'command' => 'extract',
'phar' => $pharPath,
'output' => $this->tmp,
]
);

$expectedFiles = [
'.hidden' => 'baz',
'foo' => 'bar',
];

$actualFiles = $this->collectExtractedFiles();

$this->assertEqualsCanonicalizing($expectedFiles, $actualFiles);

$expectedOutput = <<<'OUTPUT'
[OK]


OUTPUT;

$actual = DisplayNormalizer::removeTrailingSpaces($this->commandTester->getDisplay(true));

$this->assertSame($expectedOutput, $actual);
$this->assertSame(0, $this->commandTester->getStatusCode());
}

public function test_it_can_extract_a_phar_without_the_phar_extension(): void
{
$pharPath = self::FIXTURES.'/simple-phar';

$this->commandTester->execute(
[
'command' => 'extract',
'phar' => $pharPath,
'output' => $this->tmp,
]
);

$expectedFiles = [
'.hidden' => 'baz',
'foo' => 'bar',
];

$actualFiles = $this->collectExtractedFiles();

$this->assertEqualsCanonicalizing($expectedFiles, $actualFiles);

$expectedOutput = <<<'OUTPUT'
[OK]


OUTPUT;

$actual = DisplayNormalizer::removeTrailingSpaces($this->commandTester->getDisplay(true));

$this->assertSame($expectedOutput, $actual);
$this->assertSame(0, $this->commandTester->getStatusCode());
}

public function test_it_can_extract_a_compressed_phar(): void
{
$pharPath = self::FIXTURES.'/simple-phar.phar';

$this->commandTester->execute(
[
'command' => 'extract',
'phar' => $pharPath,
'output' => $this->tmp,
]
);

$expectedFiles = [
'.hidden' => 'baz',
'foo' => 'bar',
];

$actualFiles = $this->collectExtractedFiles();

$this->assertEqualsCanonicalizing($expectedFiles, $actualFiles);

$expectedOutput = <<<'OUTPUT'
[OK]


OUTPUT;

$actual = DisplayNormalizer::removeTrailingSpaces($this->commandTester->getDisplay(true));

$this->assertSame($expectedOutput, $actual);
$this->assertSame(0, $this->commandTester->getStatusCode());
}

public function test_it_cannot_extract_an_invalid_phar(): void
{
$pharPath = self::FIXTURES.'/invalid.phar';

$this->commandTester->execute(
[
'command' => 'extract',
'phar' => $pharPath,
'output' => $this->tmp,
]
);

$expectedFiles = [];

$actualFiles = $this->collectExtractedFiles();

$this->assertEqualsCanonicalizing($expectedFiles, $actualFiles);

$expectedOutput = <<<'OUTPUT'
[ERROR] The given file is not a valid PHAR


OUTPUT;

$actual = DisplayNormalizer::removeTrailingSpaces($this->commandTester->getDisplay(true));
$actual = preg_replace('/file[\ \n]+"/', 'file "', $actual);

$this->assertSame($expectedOutput, $actual);
$this->assertSame(1, $this->commandTester->getStatusCode());
}

public function test_it_provides_the_original_exception_in_debug_mode_when_cannot_extract_an_invalid_phar(): void
{
$pharPath = self::FIXTURES.'/invalid.phar';

try {
$this->commandTester->execute(
[
'command' => 'extract',
'phar' => $pharPath,
'output' => $this->tmp,
],
['verbosity' => OutputInterface::VERBOSITY_DEBUG]
);

$this->fail('Expected exception to be thrown.');
} catch (RuntimeException $exception) {
$this->assertSame(
'The given file is not a valid PHAR',
$exception->getMessage()
);
$this->assertSame(0, $exception->getCode());
$this->assertNotNull($exception->getPrevious());

$previous = $exception->getPrevious();

$this->assertInstanceOf(UnexpectedValueException::class, $previous);
$this->assertStringStartsWith('internal corruption of phar', $previous->getMessage());
}
}

public function test_it_cannot_extract_an_invalid_phar_without_extension(): void
{
$pharPath = self::FIXTURES.'/invalid';

$this->commandTester->execute(
[
'command' => 'extract',
'phar' => $pharPath,
'output' => $this->tmp,
]
);

$expectedFiles = [];

$actualFiles = $this->collectExtractedFiles();

$this->assertSame($expectedFiles, $actualFiles);

$expectedOutput = <<<'OUTPUT'
[ERROR] The given file is not a valid PHAR


OUTPUT;

$actual = DisplayNormalizer::removeTrailingSpaces($this->commandTester->getDisplay(true));
$actual = preg_replace('/file[\ \n]+"/', 'file "', $actual);

$this->assertSame($expectedOutput, $actual);
$this->assertSame(1, $this->commandTester->getStatusCode());
}

public function test_it_cannot_extract_an_unknown_file(): void
{
$this->commandTester->execute(
[
'command' => 'extract',
'phar' => '/unknown',
'output' => $this->tmp,
]
);

$expectedFiles = [];

$actualFiles = $this->collectExtractedFiles();

$this->assertSame($expectedFiles, $actualFiles);

$expectedOutput = <<<'OUTPUT'
[ERROR] The file "/unknown" could not be found.


OUTPUT;

$actual = DisplayNormalizer::removeTrailingSpaces($this->commandTester->getDisplay(true));
$actual = preg_replace('/file[\ \n]+"/', 'file "', $actual);

$this->assertSame($expectedOutput, $actual);
$this->assertSame(1, $this->commandTester->getStatusCode());
}

/**
* @return array<string,string>
*/
private function collectExtractedFiles(): array
{
$finder = Finder::create()
->files()
->in($this->tmp)
->ignoreDotFiles(false)
;

$files = [];

foreach ($finder as $file) {
/** @var SplFileInfo $file */
$filePath = make_path_relative($file->getPathname(), $this->tmp);

$files[$filePath] = $file->getContents();
}

return $files;
}
}

0 comments on commit bd569e3

Please sign in to comment.