Skip to content

Commit 63e76f2

Browse files
authored
Merge pull request #677 from asgrim/676-fix-gh-api-break
676: fix GitHub Attestation bundle API BC break
2 parents 9bc9766 + a3dcadf commit 63e76f2

9 files changed

Lines changed: 188 additions & 33 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"symfony/console": "^6.4.42",
3838
"symfony/event-dispatcher": "^6.4.37",
3939
"symfony/process": "^6.4.41",
40-
"thephpf/attestation": "^0.0.5",
40+
"thephpf/attestation": "^0.0.7",
4141
"webmozart/assert": "^1.12.1"
4242
},
4343
"require-dev": {

composer.lock

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

src/Command/SelfUpdateCommand.php

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
use Php\Pie\SelfManage\Update\IsBrewInstallation;
1818
use Php\Pie\SelfManage\Update\ReleaseIsNewer;
1919
use Php\Pie\SelfManage\Update\ReleaseMetadata;
20-
use Php\Pie\SelfManage\Verify\FailedToVerifyRelease;
20+
use Php\Pie\SelfManage\Verify\RecoverFromFailedVerification;
2121
use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation;
2222
use Php\Pie\Settings;
2323
use Php\Pie\Util\Emoji;
@@ -27,6 +27,7 @@
2727
use Symfony\Component\Console\Command\Command;
2828
use Symfony\Component\Console\Input\InputInterface;
2929
use Symfony\Component\Console\Input\InputOption;
30+
use Symfony\Component\Console\Output\ConsoleOutputInterface;
3031
use Symfony\Component\Console\Output\OutputInterface;
3132
use Throwable;
3233

@@ -178,17 +179,17 @@ public function execute(InputInterface $input, OutputInterface $output): int
178179

179180
try {
180181
$verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
181-
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
182-
$this->io->writeError(sprintf(
183-
'<error>❌ Failed to verify the pie.phar release %s: %s</error>',
184-
$latestRelease->tag,
185-
$failedToVerifyRelease->getMessage(),
186-
));
187-
188-
$this->io->writeError('This means I could not verify that the PHAR we tried to update to was authentic, so I am aborting the self-update.');
189-
unlink($pharFilename->filePath);
182+
} catch (Throwable $verificationFailure) {
183+
$this->getApplication()?->renderThrowable(
184+
$verificationFailure,
185+
$output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output,
186+
);
190187

191-
return Command::FAILURE;
188+
if (! (new RecoverFromFailedVerification())($this->io, $latestRelease, $verificationFailure)) {
189+
unlink($pharFilename->filePath);
190+
191+
return Command::FAILURE;
192+
}
192193
}
193194

194195
$pharContents = file_get_contents($pharFilename->filePath);

src/Command/SelfVerifyCommand.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
use Php\Pie\File\FullPathToSelf;
1414
use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub;
1515
use Php\Pie\SelfManage\Update\ReleaseMetadata;
16-
use Php\Pie\SelfManage\Verify\FailedToVerifyRelease;
1716
use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation;
1817
use Php\Pie\Util\Emoji;
1918
use Php\Pie\Util\PieVersion;
@@ -22,7 +21,9 @@
2221
use Symfony\Component\Console\Command\Command;
2322
use Symfony\Component\Console\Input\InputArgument;
2423
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\Console\Output\ConsoleOutputInterface;
2525
use Symfony\Component\Console\Output\OutputInterface;
26+
use Throwable;
2627

2728
use function sprintf;
2829

@@ -96,12 +97,16 @@ public function execute(InputInterface $input, OutputInterface $output): int
9697

9798
try {
9899
$verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
99-
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
100+
} catch (Throwable $verificationFailure) {
100101
$this->io->writeError(sprintf(
101102
'<error>❌ Failed to verify that this PIE binary is the authentic release %s: %s</error>',
102103
$latestRelease->tag,
103-
$failedToVerifyRelease->getMessage(),
104+
$verificationFailure->getMessage(),
104105
));
106+
$this->getApplication()?->renderThrowable(
107+
$verificationFailure,
108+
$output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output,
109+
);
105110

106111
return Command::FAILURE;
107112
}

src/SelfManage/Verify/FailedToVerifyRelease.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use RuntimeException;
99
use Symfony\Component\Process\Exception\ProcessFailedException;
1010
use ThePhpFoundation\Attestation\Verification\Exception\FailedToVerifyArtifact;
11+
use Throwable;
1112

1213
use function sprintf;
1314
use function trim;
@@ -24,6 +25,18 @@ public static function fromNoOpenssl(): self
2425
return new self('Unable to verify without `gh` CLI tool, or openssl extension.');
2526
}
2627

28+
public static function fromUnexpectedException(Throwable $throwable): self
29+
{
30+
return new self(
31+
sprintf(
32+
'An unexpected error occurred while verifying the release (%s: %s)',
33+
$throwable::class,
34+
$throwable->getMessage(),
35+
),
36+
previous: $throwable,
37+
);
38+
}
39+
2740
public static function fromGhCliFailure(ReleaseMetadata $releaseMetadata, ProcessFailedException $processFailedException): self
2841
{
2942
return new self(

src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use ThePhpFoundation\Attestation\FulcioSigstoreOidExtensions;
1414
use ThePhpFoundation\Attestation\Verification\Exception\FailedToVerifyArtifact;
1515
use ThePhpFoundation\Attestation\Verification\VerifyAttestation;
16+
use Throwable;
1617

1718
use function sprintf;
1819

@@ -70,6 +71,8 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam
7071
);
7172
} catch (FailedToVerifyArtifact $failedToVerifyArtifact) {
7273
throw FailedToVerifyRelease::fromAttestationException($failedToVerifyArtifact);
74+
} catch (Throwable $throwable) {
75+
throw FailedToVerifyRelease::fromUnexpectedException($throwable);
7376
}
7477

7578
$io->write(sprintf(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\Pie\SelfManage\Verify;
6+
7+
use Composer\IO\IOInterface;
8+
use Php\Pie\SelfManage\Update\ReleaseMetadata;
9+
use Php\Pie\Util\Emoji;
10+
use Throwable;
11+
12+
use function sprintf;
13+
14+
/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
15+
final class RecoverFromFailedVerification
16+
{
17+
/**
18+
* @return bool true if the caller should continue the update WITHOUT
19+
* verification (user opted in, at their own risk); false
20+
* if the caller should abort.
21+
*/
22+
public function __invoke(IOInterface $io, ReleaseMetadata $releaseMetadata, Throwable $verificationFailure): bool
23+
{
24+
$io->writeError(sprintf(
25+
'<error>%s Failed to verify the pie.phar release %s: %s</error>',
26+
Emoji::CROSS,
27+
$releaseMetadata->tag,
28+
$verificationFailure->getMessage(),
29+
));
30+
$io->writeError('<comment>This means I could not verify that the PHAR we tried to update to was authentic.</comment>');
31+
$io->writeError(sprintf(
32+
'<comment>You can manually download release %s yourself from: %s</comment>',
33+
$releaseMetadata->tag,
34+
$releaseMetadata->downloadUrl,
35+
));
36+
37+
if (! $io->isInteractive()) {
38+
$io->writeError(sprintf('<warning>%s You are not running in interactive mode, so I am aborting the self-update.</warning>', Emoji::WARNING));
39+
40+
return false;
41+
}
42+
43+
if (! $io->askConfirmation('<question>Would you like to continue the update <highlight>WITHOUT verification, at your own risk</highlight>? [y/N]</question>', false)) {
44+
$io->writeError('<comment>Ok, aborting the self-update.</comment>');
45+
46+
return false;
47+
}
48+
49+
$io->writeError(sprintf('<warning>%s Continuing the self-update WITHOUT verifying the release authenticity. This is at your own risk.</warning>', Emoji::WARNING));
50+
51+
return true;
52+
}
53+
}

test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
use PHPUnit\Framework\Attributes\CoversClass;
1717
use PHPUnit\Framework\MockObject\MockObject;
1818
use PHPUnit\Framework\TestCase;
19+
use ThePhpFoundation\Attestation\Verification\VerifyAttestation;
1920
use ThePhpFoundation\Attestation\Verification\VerifyAttestationWithOpenSsl;
21+
use Webmozart\Assert\InvalidArgumentException;
2022

2123
use function assert;
2224
use function base64_encode;
@@ -145,16 +147,7 @@ private function mockAttestationResponse(string $digestInUrl, string $dsseEnvelo
145147
$url = self::TEST_GITHUB_URL . '/orgs/php/attestations/sha256:' . $digestInUrl . '?predicate_type=provenance';
146148
$this->httpDownloader->expects(self::once())
147149
->method('get')
148-
->with(
149-
$url,
150-
[
151-
'retry-auth-failure' => true,
152-
'http' => [
153-
'method' => 'GET',
154-
'header' => [],
155-
],
156-
],
157-
)
150+
->with($url)
158151
->willReturn(
159152
new Response(
160153
['url' => $url],
@@ -369,4 +362,18 @@ public function testFailedToVerifyBecauseDigestNotFoundOnGitHub(): void
369362
$this->expectException(FailedToVerifyRelease::class);
370363
$this->verifier->verify($this->release, $this->downloadedPhar, $this->io);
371364
}
365+
366+
public function testUnexpectedThrowableFromVerifyAttestationIsWrappedInFailedToVerifyRelease(): void
367+
{
368+
$verifyAttestation = $this->createMock(VerifyAttestation::class);
369+
$verifyAttestation->method('verify')
370+
->willThrowException(new InvalidArgumentException('Expected an array. Got: NULL'));
371+
372+
$verifier = new FallbackVerificationUsingOpenSsl($verifyAttestation, $this->fetchPieRelease);
373+
374+
$this->expectException(FailedToVerifyRelease::class);
375+
$this->expectExceptionMessageMatches('/Expected an array\. Got: NULL/');
376+
377+
$verifier->verify($this->release, $this->downloadedPhar, $this->io);
378+
}
372379
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Php\PieUnitTest\SelfManage\Verify;
6+
7+
use Composer\IO\BufferIO;
8+
use Php\Pie\SelfManage\Update\ReleaseMetadata;
9+
use Php\Pie\SelfManage\Verify\RecoverFromFailedVerification;
10+
use PHPUnit\Framework\Attributes\CoversClass;
11+
use PHPUnit\Framework\TestCase;
12+
use RuntimeException;
13+
14+
#[CoversClass(RecoverFromFailedVerification::class)]
15+
final class RecoverFromFailedVerificationTest extends TestCase
16+
{
17+
private const DOWNLOAD_URL = 'https://example.localhost/pie.phar';
18+
19+
private ReleaseMetadata $release;
20+
private RuntimeException $verificationFailure;
21+
private RecoverFromFailedVerification $recover;
22+
23+
public function setUp(): void
24+
{
25+
parent::setUp();
26+
27+
$this->release = new ReleaseMetadata('1.2.3', self::DOWNLOAD_URL);
28+
$this->verificationFailure = new RuntimeException('some failure');
29+
$this->recover = new RecoverFromFailedVerification();
30+
}
31+
32+
public function testAbortsWithoutPromptingWhenNonInteractive(): void
33+
{
34+
$io = new BufferIO();
35+
36+
$result = ($this->recover)($io, $this->release, $this->verificationFailure);
37+
38+
self::assertFalse($result);
39+
$output = $io->getOutput();
40+
self::assertStringContainsString(self::DOWNLOAD_URL, $output);
41+
self::assertStringContainsString('not running in interactive mode', $output);
42+
}
43+
44+
public function testAbortsWhenUserDeclinesConfirmation(): void
45+
{
46+
$io = new BufferIO();
47+
$io->setUserInputs(['n']);
48+
49+
$result = ($this->recover)($io, $this->release, $this->verificationFailure);
50+
51+
self::assertFalse($result);
52+
$output = $io->getOutput();
53+
self::assertStringContainsString(self::DOWNLOAD_URL, $output);
54+
self::assertStringContainsString('aborting', $output);
55+
}
56+
57+
public function testContinuesWhenUserConfirms(): void
58+
{
59+
$io = new BufferIO();
60+
$io->setUserInputs(['y']);
61+
62+
$result = ($this->recover)($io, $this->release, $this->verificationFailure);
63+
64+
self::assertTrue($result);
65+
$output = $io->getOutput();
66+
self::assertStringContainsString(self::DOWNLOAD_URL, $output);
67+
self::assertStringContainsString('at your own risk', $output);
68+
}
69+
}

0 commit comments

Comments
 (0)