From fbbb8c142665b7ac89570d21d90de19f4b5aafee Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 17 Jul 2026 10:54:25 +0100 Subject: [PATCH 1/2] 676: handle upstream unexpected exceptions with more info, and option to continue anyway --- src/Command/SelfUpdateCommand.php | 23 ++++--- src/Command/SelfVerifyCommand.php | 11 ++- .../Verify/FailedToVerifyRelease.php | 13 ++++ .../FallbackVerificationUsingOpenSsl.php | 3 + .../Verify/RecoverFromFailedVerification.php | 53 ++++++++++++++ .../FallbackVerificationUsingOpenSslTest.php | 16 +++++ .../RecoverFromFailedVerificationTest.php | 69 +++++++++++++++++++ 7 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 src/SelfManage/Verify/RecoverFromFailedVerification.php create mode 100644 test/unit/SelfManage/Verify/RecoverFromFailedVerificationTest.php diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index bc7897ca..16c6ec06 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -17,7 +17,7 @@ use Php\Pie\SelfManage\Update\IsBrewInstallation; use Php\Pie\SelfManage\Update\ReleaseIsNewer; use Php\Pie\SelfManage\Update\ReleaseMetadata; -use Php\Pie\SelfManage\Verify\FailedToVerifyRelease; +use Php\Pie\SelfManage\Verify\RecoverFromFailedVerification; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Settings; use Php\Pie\Util\Emoji; @@ -27,6 +27,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -178,17 +179,17 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $verifyPiePhar->verify($latestRelease, $pharFilename, $this->io); - } catch (FailedToVerifyRelease $failedToVerifyRelease) { - $this->io->writeError(sprintf( - '❌ Failed to verify the pie.phar release %s: %s', - $latestRelease->tag, - $failedToVerifyRelease->getMessage(), - )); - - $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.'); - unlink($pharFilename->filePath); + } catch (Throwable $verificationFailure) { + $this->getApplication()?->renderThrowable( + $verificationFailure, + $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output, + ); - return Command::FAILURE; + if (! (new RecoverFromFailedVerification())($this->io, $latestRelease, $verificationFailure)) { + unlink($pharFilename->filePath); + + return Command::FAILURE; + } } $pharContents = file_get_contents($pharFilename->filePath); diff --git a/src/Command/SelfVerifyCommand.php b/src/Command/SelfVerifyCommand.php index 2fcb6cfe..da4c548c 100644 --- a/src/Command/SelfVerifyCommand.php +++ b/src/Command/SelfVerifyCommand.php @@ -13,7 +13,6 @@ use Php\Pie\File\FullPathToSelf; 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\Emoji; use Php\Pie\Util\PieVersion; @@ -22,7 +21,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function sprintf; @@ -96,12 +97,16 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $verifyPiePhar->verify($latestRelease, $pharFilename, $this->io); - } catch (FailedToVerifyRelease $failedToVerifyRelease) { + } catch (Throwable $verificationFailure) { $this->io->writeError(sprintf( '❌ Failed to verify that this PIE binary is the authentic release %s: %s', $latestRelease->tag, - $failedToVerifyRelease->getMessage(), + $verificationFailure->getMessage(), )); + $this->getApplication()?->renderThrowable( + $verificationFailure, + $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output, + ); return Command::FAILURE; } diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php index 6ecaa67e..4f6147d7 100644 --- a/src/SelfManage/Verify/FailedToVerifyRelease.php +++ b/src/SelfManage/Verify/FailedToVerifyRelease.php @@ -8,6 +8,7 @@ use RuntimeException; use Symfony\Component\Process\Exception\ProcessFailedException; use ThePhpFoundation\Attestation\Verification\Exception\FailedToVerifyArtifact; +use Throwable; use function sprintf; use function trim; @@ -24,6 +25,18 @@ public static function fromNoOpenssl(): self return new self('Unable to verify without `gh` CLI tool, or openssl extension.'); } + public static function fromUnexpectedException(Throwable $throwable): self + { + return new self( + sprintf( + 'An unexpected error occurred while verifying the release (%s: %s)', + $throwable::class, + $throwable->getMessage(), + ), + previous: $throwable, + ); + } + public static function fromGhCliFailure(ReleaseMetadata $releaseMetadata, ProcessFailedException $processFailedException): self { return new self( diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php index 5cc9676f..a75baf0c 100644 --- a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -13,6 +13,7 @@ use ThePhpFoundation\Attestation\FulcioSigstoreOidExtensions; use ThePhpFoundation\Attestation\Verification\Exception\FailedToVerifyArtifact; use ThePhpFoundation\Attestation\Verification\VerifyAttestation; +use Throwable; use function sprintf; @@ -70,6 +71,8 @@ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilenam ); } catch (FailedToVerifyArtifact $failedToVerifyArtifact) { throw FailedToVerifyRelease::fromAttestationException($failedToVerifyArtifact); + } catch (Throwable $throwable) { + throw FailedToVerifyRelease::fromUnexpectedException($throwable); } $io->write(sprintf( diff --git a/src/SelfManage/Verify/RecoverFromFailedVerification.php b/src/SelfManage/Verify/RecoverFromFailedVerification.php new file mode 100644 index 00000000..6daef44e --- /dev/null +++ b/src/SelfManage/Verify/RecoverFromFailedVerification.php @@ -0,0 +1,53 @@ +writeError(sprintf( + '%s Failed to verify the pie.phar release %s: %s', + Emoji::CROSS, + $releaseMetadata->tag, + $verificationFailure->getMessage(), + )); + $io->writeError('This means I could not verify that the PHAR we tried to update to was authentic.'); + $io->writeError(sprintf( + 'You can manually download release %s yourself from: %s', + $releaseMetadata->tag, + $releaseMetadata->downloadUrl, + )); + + if (! $io->isInteractive()) { + $io->writeError(sprintf('%s You are not running in interactive mode, so I am aborting the self-update.', Emoji::WARNING)); + + return false; + } + + if (! $io->askConfirmation('Would you like to continue the update WITHOUT verification, at your own risk? [y/N]', false)) { + $io->writeError('Ok, aborting the self-update.'); + + return false; + } + + $io->writeError(sprintf('%s Continuing the self-update WITHOUT verifying the release authenticity. This is at your own risk.', Emoji::WARNING)); + + return true; + } +} diff --git a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php index 6c739c3f..05ee24fb 100644 --- a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php +++ b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php @@ -16,7 +16,9 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use ThePhpFoundation\Attestation\Verification\VerifyAttestation; use ThePhpFoundation\Attestation\Verification\VerifyAttestationWithOpenSsl; +use Webmozart\Assert\InvalidArgumentException; use function assert; use function base64_encode; @@ -369,4 +371,18 @@ public function testFailedToVerifyBecauseDigestNotFoundOnGitHub(): void $this->expectException(FailedToVerifyRelease::class); $this->verifier->verify($this->release, $this->downloadedPhar, $this->io); } + + public function testUnexpectedThrowableFromVerifyAttestationIsWrappedInFailedToVerifyRelease(): void + { + $verifyAttestation = $this->createMock(VerifyAttestation::class); + $verifyAttestation->method('verify') + ->willThrowException(new InvalidArgumentException('Expected an array. Got: NULL')); + + $verifier = new FallbackVerificationUsingOpenSsl($verifyAttestation, $this->fetchPieRelease); + + $this->expectException(FailedToVerifyRelease::class); + $this->expectExceptionMessageMatches('/Expected an array\. Got: NULL/'); + + $verifier->verify($this->release, $this->downloadedPhar, $this->io); + } } diff --git a/test/unit/SelfManage/Verify/RecoverFromFailedVerificationTest.php b/test/unit/SelfManage/Verify/RecoverFromFailedVerificationTest.php new file mode 100644 index 00000000..694a87cd --- /dev/null +++ b/test/unit/SelfManage/Verify/RecoverFromFailedVerificationTest.php @@ -0,0 +1,69 @@ +release = new ReleaseMetadata('1.2.3', self::DOWNLOAD_URL); + $this->verificationFailure = new RuntimeException('some failure'); + $this->recover = new RecoverFromFailedVerification(); + } + + public function testAbortsWithoutPromptingWhenNonInteractive(): void + { + $io = new BufferIO(); + + $result = ($this->recover)($io, $this->release, $this->verificationFailure); + + self::assertFalse($result); + $output = $io->getOutput(); + self::assertStringContainsString(self::DOWNLOAD_URL, $output); + self::assertStringContainsString('not running in interactive mode', $output); + } + + public function testAbortsWhenUserDeclinesConfirmation(): void + { + $io = new BufferIO(); + $io->setUserInputs(['n']); + + $result = ($this->recover)($io, $this->release, $this->verificationFailure); + + self::assertFalse($result); + $output = $io->getOutput(); + self::assertStringContainsString(self::DOWNLOAD_URL, $output); + self::assertStringContainsString('aborting', $output); + } + + public function testContinuesWhenUserConfirms(): void + { + $io = new BufferIO(); + $io->setUserInputs(['y']); + + $result = ($this->recover)($io, $this->release, $this->verificationFailure); + + self::assertTrue($result); + $output = $io->getOutput(); + self::assertStringContainsString(self::DOWNLOAD_URL, $output); + self::assertStringContainsString('at your own risk', $output); + } +} From 731dbb617305ef2d8a96f4ddc3c6ee3c93d7402f Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 17 Jul 2026 11:36:40 +0100 Subject: [PATCH 2/2] 676: update to attestation 0.0.6 to fix bundle BC break --- composer.json | 2 +- composer.lock | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 0b1fbfa7..32c4d556 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "symfony/console": "^6.4.42", "symfony/event-dispatcher": "^6.4.37", "symfony/process": "^6.4.41", - "thephpf/attestation": "^0.0.5", + "thephpf/attestation": "^0.0.6", "webmozart/assert": "^1.12.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 61316a62..54153cf8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3982d78a3e0fcd2965c80988a4dd1a91", + "content-hash": "14a9212a758a1601fc0c85a002b84cef", "packages": [ { "name": "composer/ca-bundle", @@ -2507,16 +2507,16 @@ }, { "name": "thephpf/attestation", - "version": "0.0.5", + "version": "0.0.6", "source": { "type": "git", "url": "https://github.com/ThePHPF/attestation.git", - "reference": "fa81efb3f6f8147287ebaebd81ff6688e0f2ec9d" + "reference": "bd16b4183b8de45982c3088595751acfe365b836" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ThePHPF/attestation/zipball/fa81efb3f6f8147287ebaebd81ff6688e0f2ec9d", - "reference": "fa81efb3f6f8147287ebaebd81ff6688e0f2ec9d", + "url": "https://api.github.com/repos/ThePHPF/attestation/zipball/bd16b4183b8de45982c3088595751acfe365b836", + "reference": "bd16b4183b8de45982c3088595751acfe365b836", "shasum": "" }, "require": { @@ -2532,10 +2532,14 @@ "phpunit/phpunit": "^9.6.25" }, "suggest": { - "ext-openssl": "Needed to verify certificates using OpenSSL" + "ext-openssl": "Needed to verify certificates using OpenSSL", + "ext-snappy": "Decompress bundle attestations faster; if not, flow-php/snappy PHP fallback is used" }, "type": "library", "autoload": { + "files": [ + "src/FlowSnappy/polyfill.php" + ], "psr-4": { "ThePhpFoundation\\Attestation\\": "src/" } @@ -2553,7 +2557,7 @@ "description": "A PHP library to aid in verifying artifact attestations", "support": { "issues": "https://github.com/ThePHPF/attestation/issues", - "source": "https://github.com/ThePHPF/attestation/tree/0.0.5" + "source": "https://github.com/ThePHPF/attestation/tree/0.0.6" }, "funding": [ { @@ -2565,7 +2569,7 @@ "type": "open_collective" } ], - "time": "2025-11-27T15:31:24+00:00" + "time": "2026-07-17T10:26:43+00:00" }, { "name": "webmozart/assert",