Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin/pie
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use Php\Pie\Command\InstallCommand;
use Php\Pie\Command\RepositoryAddCommand;
use Php\Pie\Command\RepositoryListCommand;
use Php\Pie\Command\RepositoryRemoveCommand;
use Php\Pie\Command\SelfUpdateCommand;
use Php\Pie\Command\ShowCommand;
use Php\Pie\Command\UninstallCommand;
use Php\Pie\Util\PieVersion;
Expand Down Expand Up @@ -42,6 +43,7 @@ $application->setCommandLoader(new ContainerCommandLoader(
'repository:add' => RepositoryAddCommand::class,
'repository:remove' => RepositoryRemoveCommand::class,
'uninstall' => UninstallCommand::class,
'self-update' => SelfUpdateCommand::class,
]
));

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"webmozart/assert": "^1.11"
},
"require-dev": {
"ext-openssl": "*",
"behat/behat": "^3.19.0",
"doctrine/coding-standard": "^13.0",
"phpunit/phpunit": "^10.5.45",
Expand Down
10 changes: 6 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions resources/trusted-root.jsonl

Large diffs are not rendered by default.

173 changes: 173 additions & 0 deletions src/Command/SelfUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

declare(strict_types=1);

namespace Php\Pie\Command;

use Composer\Semver\Semver;
use Composer\Util\AuthHelper;
use Composer\Util\HttpDownloader;
use Php\Pie\ComposerIntegration\PieComposerFactory;
use Php\Pie\ComposerIntegration\PieComposerRequest;
use Php\Pie\ComposerIntegration\QuieterConsoleIO;
use Php\Pie\File\SudoFilePut;
use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub;
use Php\Pie\SelfManage\Update\ReleaseMetadata;
use Php\Pie\SelfManage\Verify\FailedToVerifyRelease;
use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation;
use Php\Pie\Util\PieVersion;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

use function file_get_contents;
use function getcwd;
use function preg_match;
use function realpath;
use function sprintf;
use function unlink;

use const DIRECTORY_SEPARATOR;

#[AsCommand(
name: 'self-update',
description: 'Self update PIE',
)]
final class SelfUpdateCommand extends Command
{
private const OPTION_NIGHTLY_UPDATE = 'nightly';

/** @param non-empty-string $githubApiBaseUrl */
public function __construct(
private readonly string $githubApiBaseUrl,
private readonly QuieterConsoleIO $io,
private readonly ContainerInterface $container,
) {
parent::__construct();
}

public function configure(): void
{
parent::configure();

CommandHelper::configurePhpConfigOptions($this);
$this->addOption(
self::OPTION_NIGHTLY_UPDATE,
null,
InputOption::VALUE_NONE,
'Update to the latest nightly version.',
);
}

public function execute(InputInterface $input, OutputInterface $output): int
{
if (! PieVersion::isPharBuild()) {
$output->writeln('<comment>Aborting! You are not running a PHAR, cannot self-update.</comment>');

return 1;
}

$targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output);

$composer = PieComposerFactory::createPieComposer(
$this->container,
PieComposerRequest::noOperation(
$output,
$targetPlatform,
),
);

$httpDownloader = new HttpDownloader($this->io, $composer->getConfig());
$authHelper = new AuthHelper($this->io, $composer->getConfig());
$fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper);
$verifyPiePhar = VerifyPieReleaseUsingAttestation::factory($this->githubApiBaseUrl, $httpDownloader, $authHelper);

if ($input->hasOption(self::OPTION_NIGHTLY_UPDATE) && $input->getOption(self::OPTION_NIGHTLY_UPDATE)) {
$latestRelease = new ReleaseMetadata(
'nightly',
'https://php.github.io/pie/pie-nightly.phar',
);

$output->writeln('Downloading the latest nightly release.');
} else {
$latestRelease = $fetchLatestPieRelease->latestReleaseMetadata();
$pieVersion = PieVersion::get();

if (preg_match('/^(?<tag>.+)@(?<hash>[a-f0-9]{7})$/', $pieVersion, $matches)) {
// Have to change the version to something the Semver library understands
$pieVersion = sprintf('dev-main#%s', $matches['hash']);
$output->writeln(sprintf(
'It looks like you are running a nightly build; if you want to get the newest nightly, specify the --%s flag.',
self::OPTION_NIGHTLY_UPDATE,
));
}

$output->writeln(sprintf('You are currently running PIE version %s', $pieVersion));

if (! Semver::satisfies($latestRelease->tag, '> ' . $pieVersion)) {
$output->writeln('<info>You already have the latest version 😍</info>');

return Command::SUCCESS;
}

$output->writeln(
sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag),
OutputInterface::VERBOSITY_VERBOSE,
);
}

$pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease);

$output->writeln(
sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum),
OutputInterface::VERBOSITY_VERBOSE,
);

try {
$verifyPiePhar->verify($latestRelease, $pharFilename, $output);
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
$output->writeln(sprintf(
'<error>❌ Failed to verify the pie.phar release %s: %s</error>',
$latestRelease->tag,
$failedToVerifyRelease->getMessage(),
));

$output->writeln('This means I could not verify that the PHAR we tried to update to was authentic, so I am aborting the self-update.');
unlink($pharFilename->filePath);

return Command::FAILURE;
}

$phpSelf = $_SERVER['PHP_SELF'] ?? '';
$fullPathToSelf = $this->isAbsolutePath($phpSelf) ? $phpSelf : (getcwd() . DIRECTORY_SEPARATOR . $phpSelf);
$output->writeln(
sprintf('Writing new version to %s', $fullPathToSelf),
OutputInterface::VERBOSITY_VERBOSE,
);
SudoFilePut::contents($fullPathToSelf, file_get_contents($pharFilename->filePath));

$output->writeln('<info>✅ PIE has been upgraded to ' . $latestRelease->tag . '</info>');

return Command::SUCCESS;
}

private function isAbsolutePath(string $path): bool
{
if (realpath($path) === $path) {
return true;
}

if ($path === '' || $path === '.') {
return false;
}

if (preg_match('#^[a-zA-Z]:\\\\#', $path)) {
return true;
}

return $path[0] === '/' || $path[0] === '\\';
}
}
6 changes: 6 additions & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Php\Pie\Command\RepositoryAddCommand;
use Php\Pie\Command\RepositoryListCommand;
use Php\Pie\Command\RepositoryRemoveCommand;
use Php\Pie\Command\SelfUpdateCommand;
use Php\Pie\Command\ShowCommand;
use Php\Pie\Command\UninstallCommand;
use Php\Pie\ComposerIntegration\MinimalHelperSet;
Expand Down Expand Up @@ -56,6 +57,7 @@ public static function factory(): ContainerInterface
$container->singleton(RepositoryAddCommand::class);
$container->singleton(RepositoryRemoveCommand::class);
$container->singleton(UninstallCommand::class);
$container->singleton(SelfUpdateCommand::class);

$container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO {
return new QuieterConsoleIO(
Expand All @@ -76,6 +78,10 @@ public static function factory(): ContainerInterface
->needs('$githubApiBaseUrl')
->give('https://api.github.com');

$container->when(SelfUpdateCommand::class)
->needs('$githubApiBaseUrl')
->give('https://api.github.com');

$container->singleton(
Build::class,
static function (ContainerInterface $container): Build {
Expand Down
3 changes: 2 additions & 1 deletion src/File/BinaryFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Php\Pie\Util;

use function file_exists;
use function hash_equals;
use function hash_file;

/**
Expand Down Expand Up @@ -53,7 +54,7 @@ public function verifyAgainstOther(self $other): void
throw BinaryFileFailedVerification::fromFilenameMismatch($this, $other);
}

if ($other->checksum !== $this->checksum) {
if (! hash_equals($this->checksum, $other->checksum)) {
throw BinaryFileFailedVerification::fromChecksumMismatch($this, $other);
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/SelfManage/Update/FetchPieRelease.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Php\Pie\SelfManage\Update;

use Php\Pie\File\BinaryFile;

/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
interface FetchPieRelease
{
public function latestReleaseMetadata(): ReleaseMetadata;

/** Download the given pie.phar and return the filename (should be a temp file) */
public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile;
}
102 changes: 102 additions & 0 deletions src/SelfManage/Update/FetchPieReleaseFromGitHub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Php\Pie\SelfManage\Update;

use Composer\Util\AuthHelper;
use Composer\Util\HttpDownloader;
use Php\Pie\File\BinaryFile;
use RuntimeException;
use Webmozart\Assert\Assert;

use function array_filter;
use function array_map;
use function file_put_contents;
use function reset;
use function sys_get_temp_dir;
use function tempnam;

/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
final class FetchPieReleaseFromGitHub implements FetchPieRelease
{
private const PIE_PHAR_NAME = 'pie.phar';
private const PIE_LATEST_RELEASE_URL = '/repos/php/pie/releases/latest';

public function __construct(
private readonly string $githubApiBaseUrl,
private readonly HttpDownloader $httpDownloader,
private readonly AuthHelper $authHelper,
) {
}

public function latestReleaseMetadata(): ReleaseMetadata
{
$url = $this->githubApiBaseUrl . self::PIE_LATEST_RELEASE_URL;

$decodedRepsonse = $this->httpDownloader->get(
$url,
[
'retry-auth-failure' => false,
'http' => [
'method' => 'GET',
'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $url),
],
],
)->decodeJson();

Assert::isArray($decodedRepsonse);
Assert::keyExists($decodedRepsonse, 'tag_name');
Assert::stringNotEmpty($decodedRepsonse['tag_name']);
Assert::keyExists($decodedRepsonse, 'assets');
Assert::isList($decodedRepsonse['assets']);

$assetsNamedPiePhar = array_filter(
array_map(
/** @return array{name: non-empty-string, browser_download_url: non-empty-string, ...} */
static function (array $asset): array {
Assert::keyExists($asset, 'name');
Assert::stringNotEmpty($asset['name']);
Assert::keyExists($asset, 'browser_download_url');
Assert::stringNotEmpty($asset['browser_download_url']);

return $asset;
},
$decodedRepsonse['assets'],
),
static function (array $asset): bool {
return $asset['name'] === self::PIE_PHAR_NAME;
},
);
$firstAssetNamedPiePhar = reset($assetsNamedPiePhar);

return new ReleaseMetadata(
$decodedRepsonse['tag_name'],
$firstAssetNamedPiePhar['browser_download_url'],
);
}

public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile
{
$pharContent = $this->httpDownloader->get(
$releaseMetadata->downloadUrl,
[
'retry-auth-failure' => false,
'http' => [
'method' => 'GET',
'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $releaseMetadata->downloadUrl),
],
],
)->getBody();
Assert::stringNotEmpty($pharContent);

$tempPharFilename = tempnam(sys_get_temp_dir(), 'pie_self_update_');
Assert::stringNotEmpty($tempPharFilename);

if (file_put_contents($tempPharFilename, $pharContent) === false) {
throw new RuntimeException('Failed to write downloaded PHAR to ' . $tempPharFilename);
}

return BinaryFile::fromFileWithSha256Checksum($tempPharFilename);
}
}
Loading