Skip to content

Commit bd03d4a

Browse files
authored
Merge pull request #205 from asgrim/pie-self-update
Add self-update functionality
2 parents 40ab316 + 69e34e1 commit bd03d4a

26 files changed

+1641
-39
lines changed

bin/pie

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use Php\Pie\Command\InstallCommand;
1212
use Php\Pie\Command\RepositoryAddCommand;
1313
use Php\Pie\Command\RepositoryListCommand;
1414
use Php\Pie\Command\RepositoryRemoveCommand;
15+
use Php\Pie\Command\SelfUpdateCommand;
1516
use Php\Pie\Command\ShowCommand;
1617
use Php\Pie\Command\UninstallCommand;
1718
use Php\Pie\Util\PieVersion;
@@ -42,6 +43,7 @@ $application->setCommandLoader(new ContainerCommandLoader(
4243
'repository:add' => RepositoryAddCommand::class,
4344
'repository:remove' => RepositoryRemoveCommand::class,
4445
'uninstall' => UninstallCommand::class,
46+
'self-update' => SelfUpdateCommand::class,
4547
]
4648
));
4749

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"webmozart/assert": "^1.11"
4040
},
4141
"require-dev": {
42+
"ext-openssl": "*",
4243
"behat/behat": "^3.19.0",
4344
"doctrine/coding-standard": "^13.0",
4445
"phpunit/phpunit": "^10.5.45",

composer.lock

Lines changed: 6 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/trusted-root.jsonl

Lines changed: 2 additions & 0 deletions
Large diffs are not rendered by default.

src/Command/SelfUpdateCommand.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\Pie\Command;
6+
7+
use Composer\Semver\Semver;
8+
use Composer\Util\AuthHelper;
9+
use Composer\Util\HttpDownloader;
10+
use Php\Pie\ComposerIntegration\PieComposerFactory;
11+
use Php\Pie\ComposerIntegration\PieComposerRequest;
12+
use Php\Pie\ComposerIntegration\QuieterConsoleIO;
13+
use Php\Pie\File\SudoFilePut;
14+
use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub;
15+
use Php\Pie\SelfManage\Update\ReleaseMetadata;
16+
use Php\Pie\SelfManage\Verify\FailedToVerifyRelease;
17+
use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation;
18+
use Php\Pie\Util\PieVersion;
19+
use Psr\Container\ContainerInterface;
20+
use Symfony\Component\Console\Attribute\AsCommand;
21+
use Symfony\Component\Console\Command\Command;
22+
use Symfony\Component\Console\Input\InputInterface;
23+
use Symfony\Component\Console\Input\InputOption;
24+
use Symfony\Component\Console\Output\OutputInterface;
25+
26+
use function file_get_contents;
27+
use function getcwd;
28+
use function preg_match;
29+
use function realpath;
30+
use function sprintf;
31+
use function unlink;
32+
33+
use const DIRECTORY_SEPARATOR;
34+
35+
#[AsCommand(
36+
name: 'self-update',
37+
description: 'Self update PIE',
38+
)]
39+
final class SelfUpdateCommand extends Command
40+
{
41+
private const OPTION_NIGHTLY_UPDATE = 'nightly';
42+
43+
/** @param non-empty-string $githubApiBaseUrl */
44+
public function __construct(
45+
private readonly string $githubApiBaseUrl,
46+
private readonly QuieterConsoleIO $io,
47+
private readonly ContainerInterface $container,
48+
) {
49+
parent::__construct();
50+
}
51+
52+
public function configure(): void
53+
{
54+
parent::configure();
55+
56+
CommandHelper::configurePhpConfigOptions($this);
57+
$this->addOption(
58+
self::OPTION_NIGHTLY_UPDATE,
59+
null,
60+
InputOption::VALUE_NONE,
61+
'Update to the latest nightly version.',
62+
);
63+
}
64+
65+
public function execute(InputInterface $input, OutputInterface $output): int
66+
{
67+
if (! PieVersion::isPharBuild()) {
68+
$output->writeln('<comment>Aborting! You are not running a PHAR, cannot self-update.</comment>');
69+
70+
return 1;
71+
}
72+
73+
$targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output);
74+
75+
$composer = PieComposerFactory::createPieComposer(
76+
$this->container,
77+
PieComposerRequest::noOperation(
78+
$output,
79+
$targetPlatform,
80+
),
81+
);
82+
83+
$httpDownloader = new HttpDownloader($this->io, $composer->getConfig());
84+
$authHelper = new AuthHelper($this->io, $composer->getConfig());
85+
$fetchLatestPieRelease = new FetchPieReleaseFromGitHub($this->githubApiBaseUrl, $httpDownloader, $authHelper);
86+
$verifyPiePhar = VerifyPieReleaseUsingAttestation::factory($this->githubApiBaseUrl, $httpDownloader, $authHelper);
87+
88+
if ($input->hasOption(self::OPTION_NIGHTLY_UPDATE) && $input->getOption(self::OPTION_NIGHTLY_UPDATE)) {
89+
$latestRelease = new ReleaseMetadata(
90+
'nightly',
91+
'https://php.github.io/pie/pie-nightly.phar',
92+
);
93+
94+
$output->writeln('Downloading the latest nightly release.');
95+
} else {
96+
$latestRelease = $fetchLatestPieRelease->latestReleaseMetadata();
97+
$pieVersion = PieVersion::get();
98+
99+
if (preg_match('/^(?<tag>.+)@(?<hash>[a-f0-9]{7})$/', $pieVersion, $matches)) {
100+
// Have to change the version to something the Semver library understands
101+
$pieVersion = sprintf('dev-main#%s', $matches['hash']);
102+
$output->writeln(sprintf(
103+
'It looks like you are running a nightly build; if you want to get the newest nightly, specify the --%s flag.',
104+
self::OPTION_NIGHTLY_UPDATE,
105+
));
106+
}
107+
108+
$output->writeln(sprintf('You are currently running PIE version %s', $pieVersion));
109+
110+
if (! Semver::satisfies($latestRelease->tag, '> ' . $pieVersion)) {
111+
$output->writeln('<info>You already have the latest version 😍</info>');
112+
113+
return Command::SUCCESS;
114+
}
115+
116+
$output->writeln(
117+
sprintf('Newer version %s found, going to update you... ⏳', $latestRelease->tag),
118+
OutputInterface::VERBOSITY_VERBOSE,
119+
);
120+
}
121+
122+
$pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease);
123+
124+
$output->writeln(
125+
sprintf('Verifying release with digest sha256:%s...', $pharFilename->checksum),
126+
OutputInterface::VERBOSITY_VERBOSE,
127+
);
128+
129+
try {
130+
$verifyPiePhar->verify($latestRelease, $pharFilename, $output);
131+
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
132+
$output->writeln(sprintf(
133+
'<error>❌ Failed to verify the pie.phar release %s: %s</error>',
134+
$latestRelease->tag,
135+
$failedToVerifyRelease->getMessage(),
136+
));
137+
138+
$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.');
139+
unlink($pharFilename->filePath);
140+
141+
return Command::FAILURE;
142+
}
143+
144+
$phpSelf = $_SERVER['PHP_SELF'] ?? '';
145+
$fullPathToSelf = $this->isAbsolutePath($phpSelf) ? $phpSelf : (getcwd() . DIRECTORY_SEPARATOR . $phpSelf);
146+
$output->writeln(
147+
sprintf('Writing new version to %s', $fullPathToSelf),
148+
OutputInterface::VERBOSITY_VERBOSE,
149+
);
150+
SudoFilePut::contents($fullPathToSelf, file_get_contents($pharFilename->filePath));
151+
152+
$output->writeln('<info>✅ PIE has been upgraded to ' . $latestRelease->tag . '</info>');
153+
154+
return Command::SUCCESS;
155+
}
156+
157+
private function isAbsolutePath(string $path): bool
158+
{
159+
if (realpath($path) === $path) {
160+
return true;
161+
}
162+
163+
if ($path === '' || $path === '.') {
164+
return false;
165+
}
166+
167+
if (preg_match('#^[a-zA-Z]:\\\\#', $path)) {
168+
return true;
169+
}
170+
171+
return $path[0] === '/' || $path[0] === '\\';
172+
}
173+
}

src/Container.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Php\Pie\Command\RepositoryAddCommand;
1717
use Php\Pie\Command\RepositoryListCommand;
1818
use Php\Pie\Command\RepositoryRemoveCommand;
19+
use Php\Pie\Command\SelfUpdateCommand;
1920
use Php\Pie\Command\ShowCommand;
2021
use Php\Pie\Command\UninstallCommand;
2122
use Php\Pie\ComposerIntegration\MinimalHelperSet;
@@ -56,6 +57,7 @@ public static function factory(): ContainerInterface
5657
$container->singleton(RepositoryAddCommand::class);
5758
$container->singleton(RepositoryRemoveCommand::class);
5859
$container->singleton(UninstallCommand::class);
60+
$container->singleton(SelfUpdateCommand::class);
5961

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

81+
$container->when(SelfUpdateCommand::class)
82+
->needs('$githubApiBaseUrl')
83+
->give('https://api.github.com');
84+
7985
$container->singleton(
8086
Build::class,
8187
static function (ContainerInterface $container): Build {

src/File/BinaryFile.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Php\Pie\Util;
88

99
use function file_exists;
10+
use function hash_equals;
1011
use function hash_file;
1112

1213
/**
@@ -53,7 +54,7 @@ public function verifyAgainstOther(self $other): void
5354
throw BinaryFileFailedVerification::fromFilenameMismatch($this, $other);
5455
}
5556

56-
if ($other->checksum !== $this->checksum) {
57+
if (! hash_equals($this->checksum, $other->checksum)) {
5758
throw BinaryFileFailedVerification::fromChecksumMismatch($this, $other);
5859
}
5960
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\Pie\SelfManage\Update;
6+
7+
use Php\Pie\File\BinaryFile;
8+
9+
/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
10+
interface FetchPieRelease
11+
{
12+
public function latestReleaseMetadata(): ReleaseMetadata;
13+
14+
/** Download the given pie.phar and return the filename (should be a temp file) */
15+
public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile;
16+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\Pie\SelfManage\Update;
6+
7+
use Composer\Util\AuthHelper;
8+
use Composer\Util\HttpDownloader;
9+
use Php\Pie\File\BinaryFile;
10+
use RuntimeException;
11+
use Webmozart\Assert\Assert;
12+
13+
use function array_filter;
14+
use function array_map;
15+
use function file_put_contents;
16+
use function reset;
17+
use function sys_get_temp_dir;
18+
use function tempnam;
19+
20+
/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
21+
final class FetchPieReleaseFromGitHub implements FetchPieRelease
22+
{
23+
private const PIE_PHAR_NAME = 'pie.phar';
24+
private const PIE_LATEST_RELEASE_URL = '/repos/php/pie/releases/latest';
25+
26+
public function __construct(
27+
private readonly string $githubApiBaseUrl,
28+
private readonly HttpDownloader $httpDownloader,
29+
private readonly AuthHelper $authHelper,
30+
) {
31+
}
32+
33+
public function latestReleaseMetadata(): ReleaseMetadata
34+
{
35+
$url = $this->githubApiBaseUrl . self::PIE_LATEST_RELEASE_URL;
36+
37+
$decodedRepsonse = $this->httpDownloader->get(
38+
$url,
39+
[
40+
'retry-auth-failure' => false,
41+
'http' => [
42+
'method' => 'GET',
43+
'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $url),
44+
],
45+
],
46+
)->decodeJson();
47+
48+
Assert::isArray($decodedRepsonse);
49+
Assert::keyExists($decodedRepsonse, 'tag_name');
50+
Assert::stringNotEmpty($decodedRepsonse['tag_name']);
51+
Assert::keyExists($decodedRepsonse, 'assets');
52+
Assert::isList($decodedRepsonse['assets']);
53+
54+
$assetsNamedPiePhar = array_filter(
55+
array_map(
56+
/** @return array{name: non-empty-string, browser_download_url: non-empty-string, ...} */
57+
static function (array $asset): array {
58+
Assert::keyExists($asset, 'name');
59+
Assert::stringNotEmpty($asset['name']);
60+
Assert::keyExists($asset, 'browser_download_url');
61+
Assert::stringNotEmpty($asset['browser_download_url']);
62+
63+
return $asset;
64+
},
65+
$decodedRepsonse['assets'],
66+
),
67+
static function (array $asset): bool {
68+
return $asset['name'] === self::PIE_PHAR_NAME;
69+
},
70+
);
71+
$firstAssetNamedPiePhar = reset($assetsNamedPiePhar);
72+
73+
return new ReleaseMetadata(
74+
$decodedRepsonse['tag_name'],
75+
$firstAssetNamedPiePhar['browser_download_url'],
76+
);
77+
}
78+
79+
public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile
80+
{
81+
$pharContent = $this->httpDownloader->get(
82+
$releaseMetadata->downloadUrl,
83+
[
84+
'retry-auth-failure' => false,
85+
'http' => [
86+
'method' => 'GET',
87+
'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $releaseMetadata->downloadUrl),
88+
],
89+
],
90+
)->getBody();
91+
Assert::stringNotEmpty($pharContent);
92+
93+
$tempPharFilename = tempnam(sys_get_temp_dir(), 'pie_self_update_');
94+
Assert::stringNotEmpty($tempPharFilename);
95+
96+
if (file_put_contents($tempPharFilename, $pharContent) === false) {
97+
throw new RuntimeException('Failed to write downloaded PHAR to ' . $tempPharFilename);
98+
}
99+
100+
return BinaryFile::fromFileWithSha256Checksum($tempPharFilename);
101+
}
102+
}

0 commit comments

Comments
 (0)