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",
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);
+ }
+}