diff --git a/bin/pie b/bin/pie
index 6296c5f7..d6d71fb9 100755
--- a/bin/pie
+++ b/bin/pie
@@ -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;
@@ -42,6 +43,7 @@ $application->setCommandLoader(new ContainerCommandLoader(
'repository:add' => RepositoryAddCommand::class,
'repository:remove' => RepositoryRemoveCommand::class,
'uninstall' => UninstallCommand::class,
+ 'self-update' => SelfUpdateCommand::class,
]
));
diff --git a/composer.json b/composer.json
index 7fa90b15..12d276b9 100644
--- a/composer.json
+++ b/composer.json
@@ -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",
diff --git a/composer.lock b/composer.lock
index a77cbf3d..32efbe12 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": "7797d21f9607be632029637ff4113a8f",
+ "content-hash": "0bd8be5338832e97b188b0ed6132093a",
"packages": [
{
"name": "composer/ca-bundle",
@@ -7019,15 +7019,17 @@
],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": [],
+ "stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "8.1.*||8.2.*||8.3.*||8.4.*"
},
- "platform-dev": [],
+ "platform-dev": {
+ "ext-openssl": "*"
+ },
"platform-overrides": {
"php": "8.1.99"
},
- "plugin-api-version": "2.3.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/resources/trusted-root.jsonl b/resources/trusted-root.jsonl
new file mode 100644
index 00000000..384a0321
--- /dev/null
+++ b/resources/trusted-root.jsonl
@@ -0,0 +1,2 @@
+{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[{"baseUrl":"https://rekor.sigstore.dev","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-01-12T11:53:27.000Z"}},"logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}}],"certificateAuthorities":[{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]},"validFor":{"start":"2021-03-07T03:20:29.000Z","end":"2022-12-31T23:59:59.999Z"}},{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="},{"rawBytes":"MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"}]},"validFor":{"start":"2022-04-13T20:06:15.000Z"}}],"ctlogs":[{"baseUrl":"https://ctfe.sigstore.dev/test","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-03-14T00:00:00.000Z","end":"2022-10-31T23:59:59.999Z"}},"logId":{"keyId":"CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I="}},{"baseUrl":"https://ctfe.sigstore.dev/2022","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2022-10-20T00:00:00.000Z"}},"logId":{"keyId":"3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4="}}]}
+{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","certificateAuthorities":[{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"fulcio.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICKjCCAbCgAwIBAgIUW3TJVeOvr+NSvJXdOw8nEEn7HhQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTIzMDkxMjE0MDY1NFoXDTI0MDkxMTE0MDY1NFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEsosodObhuHG6Pr5vp5y+pmnKawS1h2hwv3r3hBwqh3ZHJAw64mhDnDs9fw4jKkZEBYRSVyOHyZppz4day8hgpTIDwdj44Oan4RDb+wmj04jfhVLjLsQ4Q/X4K/ynRgNXo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUm0vkDkQZ29hutYdayJobIRmf/iMwHwYDVR0jBBgwFoAUwOG4UqRLTz7eejgRBs9JjqFFmzMwCgYIKoZIzj0EAwMDaAAwZQIwIBl93E7vkWTvdeIm1WSIM4qNsj0ApE8LCj3k1vrY5x6/7yhAZs7QlO3/FBCoEeaZAjEAlJcNr37uZq9BYHODHBeO/gP+6EfbzsNaLV22ASBlhF/a9y83ESLuqCNN7IxGxmWT"},{"rawBytes":"MIICFTCCAZugAwIBAgIUD3Jlqt4qhrcZI4UnGfPGrEq/pjQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDkxMTEyMDAwMFoXDTI4MDkwOTEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7X7nK0wC7uEmDjW+on0sXIX3FacL3hhcrhneA+M/kl1OtvQiPmFrH9lbUQqOj/AfspJ8uGY3jaq8WuSg6ghatzYfuuzLAJIK4nGpCBafncF8EynOssPq64/Dz+JUWXqlo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUwOG4UqRLTz7eejgRBs9JjqFFmzMwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAI8HWLrke7uzhOpwlD1cNixPmoX9XFKe7bEPozo0D+vKi0Gt6VlC7xPedFIw4/AypAIwQP+FGRWvfx0IAH5/n0aRiN7/LVpyFA5RkJASZOVOib2Y8pNuhXa9V3ZbWO6v6kW/"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2023-10-27T16:30:00Z","end":"2024-05-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"fulcio.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICKzCCAbCgAwIBAgIUOpyw2HaZefsj/4SPXutGof8E2CkwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI0MDUxMzAwMDAwMFoXDTI1MDUxMzAwMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJNJWvW8fckfk/oQmh+qCeIlFXl9YLEkKSjZCgcVB92Fi1HQnvmpCiyqpvP91SmT1/G6QbrmTGV7MmIQlDnBWHNUT+jwZ3elGu/yfr/v8U0uhZTIli/BMj5Y4ICHK/j4do3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUD0fF3cs+ldPyiWohHJ3JmO91V7gwHwYDVR0jBBgwFoAUwOG4UqRLTz7eejgRBs9JjqFFmzMwCgYIKoZIzj0EAwMDaQAwZgIxAO7BRC9i7oGUHjjlcHU/bfqk2NLy7t6wm3K5W+jBLFbAj6sVjYcY+rrYhop/OjclbQIxALafBKLPIPjoCI29BUHwLBFP6e92ZlyaoFtoqccceXAevRaDjXFvb5+M7wnD6AuAJw=="},{"rawBytes":"MIICFTCCAZugAwIBAgIUD3Jlqt4qhrcZI4UnGfPGrEq/pjQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDkxMTEyMDAwMFoXDTI4MDkwOTEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7X7nK0wC7uEmDjW+on0sXIX3FacL3hhcrhneA+M/kl1OtvQiPmFrH9lbUQqOj/AfspJ8uGY3jaq8WuSg6ghatzYfuuzLAJIK4nGpCBafncF8EynOssPq64/Dz+JUWXqlo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUwOG4UqRLTz7eejgRBs9JjqFFmzMwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAI8HWLrke7uzhOpwlD1cNixPmoX9XFKe7bEPozo0D+vKi0Gt6VlC7xPedFIw4/AypAIwQP+FGRWvfx0IAH5/n0aRiN7/LVpyFA5RkJASZOVOib2Y8pNuhXa9V3ZbWO6v6kW/"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-05-13T00:00:00Z","end":"2024-10-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"fulcio.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICKzCCAbCgAwIBAgIUQeyd9UH06yZ63pDuqjgUZ58CnpMwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI0MTAwMzEyMDAwMFoXDTI1MTAwMzEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEwvbET2w+j9j9j50iTInH1gb9GSXkpsCvWz5orX1zgme+/Qh/5gMkpfmgfOSLV2ZRgT1hzujYmnKQvP2mCxYnbwQELAkAf+VhEY/7Uw3zZvguGQSdF1cxzRHiMTOha5eFo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUMib9z4ZYBcQANTVvVCa3KoTGbBUwHwYDVR0jBBgwFoAUwOG4UqRLTz7eejgRBs9JjqFFmzMwCgYIKoZIzj0EAwMDaQAwZgIxAPIU/zlJiJrxn6oTWNdEAD/YBSnhyxcvpq1D2DzFy8E8hbkEfMZPErYL7HyoL/BkdwIxAN9KDEKyktEUBrfHehfcLAzI2kERJx+8DSslXswOIbLaeqYfWsmrQAt5C0X/nOWxXA=="},{"rawBytes":"MIICFTCCAZugAwIBAgIUD3Jlqt4qhrcZI4UnGfPGrEq/pjQwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDkxMTEyMDAwMFoXDTI4MDkwOTEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7X7nK0wC7uEmDjW+on0sXIX3FacL3hhcrhneA+M/kl1OtvQiPmFrH9lbUQqOj/AfspJ8uGY3jaq8WuSg6ghatzYfuuzLAJIK4nGpCBafncF8EynOssPq64/Dz+JUWXqlo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUwOG4UqRLTz7eejgRBs9JjqFFmzMwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAI8HWLrke7uzhOpwlD1cNixPmoX9XFKe7bEPozo0D+vKi0Gt6VlC7xPedFIw4/AypAIwQP+FGRWvfx0IAH5/n0aRiN7/LVpyFA5RkJASZOVOib2Y8pNuhXa9V3ZbWO6v6kW/"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-10-07T00:00:00Z"}}],"timestampAuthorities":[{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"timestamp.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICHDCCAaGgAwIBAgIUNDVlmtZuvoujn4KwiC/oxIr8hxAwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTIzMDgzMTEyMDAwMFoXDTI0MDgzMDEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEV/zJhNTdu0Fa9hGCUih/JvqEoE81tEWrAVwUXXhdRgIY9hIFErLhNo6sSOpV9d7Zuy0KWMHhcimCUr41a1732ByVRy3f+Z4QhqpsgFMh5b5J90HJLK7HOyUZjehAnvSno3gwdjAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUGwE6T5ZIh6lY9wP6vt42UHyVMewwHwYDVR0jBBgwFoAUdh+GTP65aetHLVLs9hdhGgDIKIwwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDaQAwZgIxAJo48LtrSsn0UmLtqGiUKg2EUvso+aDN5EyjpvMmobZ/Oq9zjnR7Of369hoABW4/1gIxANg5ZW4FqijhsXnA3md6jM9yLrLCI9QL+KnuZnXq6WgAcNQaAN7PNNjVDKV3iJEklw=="},{"rawBytes":"MIICJDCCAaqgAwIBAgIUckXVHpiw7iJY1V/jY8LYLj5TgqAwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTI4MDgwNTEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEISv96hTQ58QroEzzu4K+o9p8YkwDCBia2U7Y+VBNbOG/w1mLRibve9hSeUE1FSyLBMkiFSSm6MexcsbjyqOoNtRxuMinyYt6DSEox+/It2s/bTPyNAN0QP0DCQQOpnTZo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUdh+GTP65aetHLVLs9hdhGgDIKIwwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAIhf+2E5W2yOb/fCDAjhL/G/jerf74M0tG/zyo32U2keawxkzZosDdwnPaHaGLynAQIwa8nr3en4fZz1AdOZm6nK5hr1qK2F94nifgnAJ/WeT0fZnK/oHan0R28x363qYuYH"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2023-10-27T16:30:00Z","end":"2024-05-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"timestamp.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICGzCCAaGgAwIBAgIUPPgn6ner1PU/75CQ+62fdBuazaAwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTI0MDUxMzAwMDAwMFoXDTI1MDUxMzAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEA0pG1mAC4qafk1JJuOoIvhnMME9XmBDxjGFreDLnyzaexIzRw+UHUFy8C2gE6Me+0tIGQt4Ftbu66NGmfvBkR6boPMYQSU2O5X5ykZBm/9LR/Aqz0lgmBy/OlXvTJjglo3gwdjAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUDa4GVhd97Z2V8kiVl9DB0kC53CMwHwYDVR0jBBgwFoAUdh+GTP65aetHLVLs9hdhGgDIKIwwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDaAAwZQIwEQHd++b7IBAAuqT2/1i/wXf1WM2XrkFF6qd1c3kFcBVvdLQyJ5KoyNUHnfCCVJROAjEA+FoASOEcARlU6RqVcif9JthHwzh6nNwz0AfAHvO8xantN/7HjiLmFrFEGR/g0kN/"},{"rawBytes":"MIICJDCCAaqgAwIBAgIUckXVHpiw7iJY1V/jY8LYLj5TgqAwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTI4MDgwNTEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEISv96hTQ58QroEzzu4K+o9p8YkwDCBia2U7Y+VBNbOG/w1mLRibve9hSeUE1FSyLBMkiFSSm6MexcsbjyqOoNtRxuMinyYt6DSEox+/It2s/bTPyNAN0QP0DCQQOpnTZo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUdh+GTP65aetHLVLs9hdhGgDIKIwwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAIhf+2E5W2yOb/fCDAjhL/G/jerf74M0tG/zyo32U2keawxkzZosDdwnPaHaGLynAQIwa8nr3en4fZz1AdOZm6nK5hr1qK2F94nifgnAJ/WeT0fZnK/oHan0R28x363qYuYH"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-05-13T00:00:00Z","end":"2024-10-25T00:00:00Z"}},{"subject":{"organization":"GitHub, Inc.","commonName":"Internal Services Root"},"uri":"timestamp.githubapp.com","certChain":{"certificates":[{"rawBytes":"MIICGzCCAaGgAwIBAgIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTI0MTAwNDEyMDAwMFoXDTI1MTAwNDEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEM7jdYNBTeD6hjym2/y73b50u2AFQsf8305Sr1NleOqamH9aWt6obhJQH3NoNUw9iFzHcDvafYWQFMu7SmOxS5n3aqwwfR8oJxKnEl36uCmGB+8TXS3B76SVTHEhG5rzOo3gwdjAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUQvz9YbWX3S6a+jruBkhRBiE2RCkwHwYDVR0jBBgwFoAUdh+GTP65aetHLVLs9hdhGgDIKIwwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDaAAwZQIxAI4dhu5iyx/g+z1vKAAWvHtebl1ZwsC+Vwgjm6Ttlq5yLNHHvYEnJ/h15Qv2IuXvdgIwZ8H/iy4lXsFJdFYSsB1/zavl24EgxSzxK/pCpihXMetYYDA/lX3xMyquisMx45rN"},{"rawBytes":"MIICJDCCAaqgAwIBAgIUckXVHpiw7iJY1V/jY8LYLj5TgqAwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTI4MDgwNTEyMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEISv96hTQ58QroEzzu4K+o9p8YkwDCBia2U7Y+VBNbOG/w1mLRibve9hSeUE1FSyLBMkiFSSm6MexcsbjyqOoNtRxuMinyYt6DSEox+/It2s/bTPyNAN0QP0DCQQOpnTZo3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUdh+GTP65aetHLVLs9hdhGgDIKIwwHwYDVR0jBBgwFoAUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaAAwZQIxAIhf+2E5W2yOb/fCDAjhL/G/jerf74M0tG/zyo32U2keawxkzZosDdwnPaHaGLynAQIwa8nr3en4fZz1AdOZm6nK5hr1qK2F94nifgnAJ/WeT0fZnK/oHan0R28x363qYuYH"},{"rawBytes":"MIIB9TCCAXqgAwIBAgIUNFryA06EHDIcd5EIbe8swbl9OY4wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDgwNzEyMDAwMFoXDTMzMDgwNDEyMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXYaXx4H0oNuVP/2cfydA3oaafvvkkkgb5hbL8/j/BO25S7uTmDOCA5e4QLLWCKFuc+xp2j14tCH4WmHzMUDvf2tXtInVliY5wZgQMM9L6klo/IwA9x4omdcjnT+kKJAjo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUfFJ5/6rhfHEZPnXAhrQLhGkJJMwwCgYIKoZIzj0EAwMDaQAwZgIxAPzXsV+eokrqOHSQZH/XhhHE1slOscKy3DQpYpYJ1AWmJ2lJu/XOmubBX5s7apllUwIxALw2Ts8CDACiK42UymC8fk6sbNfoXUAWqdyKTVt2Lst+wNdkRniGvx7jT65BKTkcsQ=="}]},"validFor":{"start":"2024-10-07T00:00:00Z"}}]}
diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php
new file mode 100644
index 00000000..4faf1e50
--- /dev/null
+++ b/src/Command/SelfUpdateCommand.php
@@ -0,0 +1,173 @@
+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('Aborting! You are not running a PHAR, cannot self-update.');
+
+ 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('/^(?.+)@(?[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('You already have the latest version 😍');
+
+ 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(
+ '❌ Failed to verify the pie.phar release %s: %s',
+ $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('✅ PIE has been upgraded to ' . $latestRelease->tag . '');
+
+ 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] === '\\';
+ }
+}
diff --git a/src/Container.php b/src/Container.php
index 351db890..9d2da927 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -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;
@@ -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(
@@ -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 {
diff --git a/src/File/BinaryFile.php b/src/File/BinaryFile.php
index db3ae942..873e5cc6 100644
--- a/src/File/BinaryFile.php
+++ b/src/File/BinaryFile.php
@@ -7,6 +7,7 @@
use Php\Pie\Util;
use function file_exists;
+use function hash_equals;
use function hash_file;
/**
@@ -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);
}
}
diff --git a/src/SelfManage/Update/FetchPieRelease.php b/src/SelfManage/Update/FetchPieRelease.php
new file mode 100644
index 00000000..ef02be0e
--- /dev/null
+++ b/src/SelfManage/Update/FetchPieRelease.php
@@ -0,0 +1,16 @@
+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);
+ }
+}
diff --git a/src/SelfManage/Update/ReleaseMetadata.php b/src/SelfManage/Update/ReleaseMetadata.php
new file mode 100644
index 00000000..912ded2c
--- /dev/null
+++ b/src/SelfManage/Update/ReleaseMetadata.php
@@ -0,0 +1,19 @@
+ $attestation */
+ public static function fromAttestationBundleWithDsseEnvelope(array $attestation): self
+ {
+ Assert::keyExists($attestation, 'bundle');
+ Assert::isArray($attestation['bundle']);
+
+ Assert::keyExists($attestation['bundle'], 'verificationMaterial');
+ Assert::isArray($attestation['bundle']['verificationMaterial']);
+ Assert::keyExists($attestation['bundle']['verificationMaterial'], 'certificate');
+ Assert::isArray($attestation['bundle']['verificationMaterial']['certificate']);
+ Assert::keyExists($attestation['bundle']['verificationMaterial']['certificate'], 'rawBytes');
+ Assert::stringNotEmpty($attestation['bundle']['verificationMaterial']['certificate']['rawBytes']);
+
+ Assert::keyExists($attestation['bundle'], 'dsseEnvelope');
+ Assert::isArray($attestation['bundle']['dsseEnvelope']);
+ Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payload');
+ Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payload']);
+ Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'payloadType');
+ Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['payloadType']);
+ Assert::keyExists($attestation['bundle']['dsseEnvelope'], 'signatures');
+ Assert::isNonEmptyList($attestation['bundle']['dsseEnvelope']['signatures']);
+ Assert::count($attestation['bundle']['dsseEnvelope']['signatures'], 1);
+ Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'], 0);
+ Assert::isArray($attestation['bundle']['dsseEnvelope']['signatures'][0]);
+ Assert::keyExists($attestation['bundle']['dsseEnvelope']['signatures'][0], 'sig');
+ Assert::stringNotEmpty($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']);
+
+ $decoratedCertificate = "-----BEGIN CERTIFICATE-----\n"
+ . wordwrap($attestation['bundle']['verificationMaterial']['certificate']['rawBytes'], 67, "\n", true) . "\n"
+ . "-----END CERTIFICATE-----\n";
+ Assert::stringNotEmpty($decoratedCertificate);
+
+ $decodedPayload = base64_decode($attestation['bundle']['dsseEnvelope']['payload']);
+ Assert::stringNotEmpty($decodedPayload);
+
+ $decodedSignature = base64_decode($attestation['bundle']['dsseEnvelope']['signatures'][0]['sig']);
+ Assert::stringNotEmpty($decodedSignature);
+
+ return new self(
+ $decoratedCertificate,
+ $decodedPayload,
+ $attestation['bundle']['dsseEnvelope']['payloadType'],
+ $decodedSignature,
+ );
+ }
+}
diff --git a/src/SelfManage/Verify/FailedToVerifyRelease.php b/src/SelfManage/Verify/FailedToVerifyRelease.php
new file mode 100644
index 00000000..3c8fd932
--- /dev/null
+++ b/src/SelfManage/Verify/FailedToVerifyRelease.php
@@ -0,0 +1,97 @@
+tag,
+ $file->checksum,
+ ));
+ }
+
+ public static function fromSignatureVerificationFailed(int $attestationIndex, ReleaseMetadata $releaseMetadata): self
+ {
+ return new self(sprintf(
+ 'Failed to verify DSSE Envelope payload signature for attestation %d for %s',
+ $attestationIndex,
+ $releaseMetadata->tag,
+ ));
+ }
+
+ /** @param array|string $issuer */
+ public static function fromIssuerCertificateVerificationFailed(array|string $issuer): self
+ {
+ return new self(sprintf(
+ 'Failed to verify the attestation certificate was issued by trusted root %s',
+ is_array($issuer) ? implode(',', $issuer) : $issuer,
+ ));
+ }
+
+ /** @param array|string $issuer */
+ public static function fromNoIssuerCertificateInTrustedRoot(array|string $issuer): self
+ {
+ return new self(sprintf(
+ 'Could not find a trusted root certificate for issuer %s',
+ is_array($issuer) ? implode(',', $issuer) : $issuer,
+ ));
+ }
+
+ public static function fromInvalidDerEncodedStringLength(string $derEncodedString, int $expectedLength): self
+ {
+ return new self(sprintf(
+ 'DER encoded string length of "%s" was wrong; expected %d characters, was actually %d characters',
+ $derEncodedString,
+ $expectedLength,
+ strlen($derEncodedString),
+ ));
+ }
+
+ public static function fromMismatchingExtensionValues(string $extension, string $expected, string $actual): self
+ {
+ return new self(sprintf(
+ 'Attestation certificate extension %s mismatch; expected "%s", was "%s"',
+ $extension,
+ $expected,
+ $actual,
+ ));
+ }
+
+ public static function fromNoOpenssl(): self
+ {
+ return new self('Unable to verify without `gh` CLI tool, or openssl extension.');
+ }
+
+ public static function fromGhCliFailure(ReleaseMetadata $releaseMetadata, ProcessFailedException $processFailedException): self
+ {
+ return new self(
+ sprintf(
+ "`gh` CLI tool could not verify release %s\n\nError: %s",
+ $releaseMetadata->tag,
+ trim($processFailedException->getProcess()->getErrorOutput()),
+ ),
+ previous: $processFailedException,
+ );
+ }
+}
diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php
new file mode 100644
index 00000000..d5d7bffc
--- /dev/null
+++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php
@@ -0,0 +1,314 @@
+ 'https://token.actions.githubusercontent.com',
+ '1.3.6.1.4.1.57264.1.12' => 'https://github.com/php/pie',
+ '1.3.6.1.4.1.57264.1.16' => 'https://github.com/php',
+ ];
+
+ public function __construct(
+ private readonly string $trustedRootFilePath,
+ private readonly string $githubApiBaseUrl,
+ private readonly HttpDownloader $httpDownloader,
+ private readonly AuthHelper $authHelper,
+ ) {
+ }
+
+ public function verify(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename, OutputInterface $output): void
+ {
+ $output->writeln(
+ 'Falling back to basic verification. To use full verification, install the `gh` CLI tool.',
+ OutputInterface::VERBOSITY_VERBOSE,
+ );
+
+ $attestations = $this->downloadAttestations($releaseMetadata, $pharFilename);
+
+ foreach ($attestations as $attestationIndex => $attestation) {
+ /**
+ * Useful references. Whilst we don't do the full verification that
+ * `gh attestation verify` would (since we don't want to re-invent
+ * the wheel), we can do some basic check of the DSSE Envelope.
+ * We'll check the payload digest matches our expectation, and
+ * verify the signature with the certificate.
+ *
+ * - https://github.com/cli/cli/blob/234d2effd545fb9d72ea77aa648caa499aecaa6e/pkg/cmd/attestation/verify/verify.go#L225-L256
+ * - https://docs.sigstore.dev/logging/verify-release/
+ * - https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#protocol
+ */
+ $this->assertCertificateSignedByTrustedRoot($attestation);
+ $output->writeln('#' . $attestationIndex . ': Certificate was signed by a trusted root.', OutputInterface::VERBOSITY_VERBOSE);
+
+ $this->assertCertificateExtensionClaims($attestation);
+ $output->writeln('#' . $attestationIndex . ': Certificate extension claims match.', OutputInterface::VERBOSITY_VERBOSE);
+
+ $this->assertDigestFromAttestationMatchesActual($pharFilename, $attestation);
+ $output->writeln('#' . $attestationIndex . ': Payload digest matches downloaded file.', OutputInterface::VERBOSITY_VERBOSE);
+
+ $this->verifyDsseEnvelopeSignature($releaseMetadata, $attestationIndex, $attestation);
+ $output->writeln('#' . $attestationIndex . ': DSSE payload signature verified with certificate.', OutputInterface::VERBOSITY_VERBOSE);
+ }
+
+ $output->writeln('✅ Verified the new PIE version (using fallback verification)');
+ }
+
+ private function assertCertificateSignedByTrustedRoot(Attestation $attestation): void
+ {
+ $attestationCertificateInfo = openssl_x509_parse($attestation->certificate);
+
+ // @todo process in place to make sure this gets updated frequently enough: gh attestation trusted-root > resources/trusted-root.jsonl
+ $trustedRootJsonLines = explode("\n", trim(file_get_contents($this->trustedRootFilePath)));
+
+ /**
+ * Now go through our trusted root certificates and attempt to verify that the certificate was signed by an
+ * in-date trusted root certificate. The root certificates should be periodically and frequently updated using:
+ *
+ * gh attestation trusted-root > resources/trusted-root.jsonl
+ *
+ * And verifying the contents afterwards to ensure they have not been compromised. This list of JSON blobs may
+ * have multiple certificates (e.g. root certificates, intermediate certificates, expired certificates, etc.)
+ * so we should loop over to find the correct certificate used to sign the attestation certificate.
+ */
+ foreach ($trustedRootJsonLines as $jsonLine) {
+ /** @var mixed $decoded */
+ $decoded = json_decode($jsonLine, true);
+
+ // No certificate authorities defined in this JSON line, skip it...
+ if (
+ ! is_array($decoded)
+ || ! array_key_exists('certificateAuthorities', $decoded)
+ || ! is_array($decoded['certificateAuthorities'])
+ ) {
+ continue;
+ }
+
+ /** @var mixed $certificateAuthority */
+ foreach ($decoded['certificateAuthorities'] as $certificateAuthority) {
+ // We don't have a certificate chain defined, skip it...
+ if (
+ ! is_array($certificateAuthority)
+ || ! array_key_exists('certChain', $certificateAuthority)
+ || ! is_array($certificateAuthority['certChain'])
+ || ! array_key_exists('certificates', $certificateAuthority['certChain'])
+ || ! is_array($certificateAuthority['certChain']['certificates'])
+ ) {
+ continue;
+ }
+
+ /** @var mixed $caCertificateWrapper */
+ foreach ($certificateAuthority['certChain']['certificates'] as $caCertificateWrapper) {
+ // Certificate is not in the expected format, i.e. no rawBytes key, skip it...
+ if (
+ ! is_array($caCertificateWrapper)
+ || ! array_key_exists('rawBytes', $caCertificateWrapper)
+ || ! is_string($caCertificateWrapper['rawBytes'])
+ || $caCertificateWrapper['rawBytes'] === ''
+ ) {
+ continue;
+ }
+
+ // Embed the base64-encoded DER into a PEM envelope for consumption by OpenSSL.
+ $caCertificateString = sprintf(
+ <<<'EOT'
+ -----BEGIN CERTIFICATE-----
+ %s
+ -----END CERTIFICATE-----
+ EOT,
+ wordwrap($caCertificateWrapper['rawBytes'], 67, "\n", true),
+ );
+
+ $caCertificateInfo = openssl_x509_parse($caCertificateString);
+
+ // If the CA certificate subject is not the issuer of the attestation certificate,
+ // this was not the cert we were looking for, skip it...
+ if ($caCertificateInfo['subject'] !== $attestationCertificateInfo['issuer']) {
+ continue;
+ }
+
+ // Finally, verify that the located CA cert was used to sign the attestation certificate
+ if (openssl_x509_verify($attestation->certificate, $caCertificateString) !== 1) {
+ /** @psalm-suppress MixedArgument */
+ throw FailedToVerifyRelease::fromIssuerCertificateVerificationFailed($attestationCertificateInfo['issuer']);
+ }
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * If we got here, we skipped all the certificates in the trusted root collection for various reasons; so we
+ * therefore cannot trust the attestation certificate.
+ *
+ * @psalm-suppress MixedArgument
+ */
+ throw FailedToVerifyRelease::fromNoIssuerCertificateInTrustedRoot($attestationCertificateInfo['issuer']);
+ }
+
+ private function assertCertificateExtensionClaims(Attestation $attestation): void
+ {
+ $attestationCertificateInfo = openssl_x509_parse($attestation->certificate);
+ Assert::isArray($attestationCertificateInfo['extensions']);
+
+ /**
+ * See {@link https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#136141572641--fulcio} for details
+ * on the Fulcio extension keys; note the values are DER-encoded strings; the ASN.1 tag is UTF8String (0x0C).
+ *
+ * Check the extension values are what we expect; these are hard-coded, as we don't expect them
+ * to change unless the namespace/repo name change, etc.
+ */
+ foreach (self::ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES as $extension => $expectedValue) {
+ Assert::keyExists($attestationCertificateInfo['extensions'], $extension);
+ Assert::stringNotEmpty($attestationCertificateInfo['extensions'][$extension]);
+ $actualValue = $attestationCertificateInfo['extensions'][$extension];
+
+ // First character (the ASN.1 tag) is expected to be UTF8String (0x0C)
+ if (ord($actualValue[0]) !== 0x0C) {
+ throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $actualValue);
+ }
+
+ /**
+ * Second character is expected to be the length of the actual value
+ * as long as they are less than 127 bytes (short form)
+ *
+ * @link https://www.oss.com/asn1/resources/asn1-made-simple/asn1-quick-reference/basic-encoding-rules.html#Lengths
+ */
+ $expectedValueLength = ord($actualValue[1]);
+ if (strlen($actualValue) !== 2 + $expectedValueLength) {
+ throw FailedToVerifyRelease::fromInvalidDerEncodedStringLength($actualValue, 2 + $expectedValueLength);
+ }
+
+ $derDecodedValue = substr($actualValue, 2, $expectedValueLength);
+ if ($derDecodedValue !== $expectedValue) {
+ throw FailedToVerifyRelease::fromMismatchingExtensionValues($extension, $expectedValue, $derDecodedValue);
+ }
+ }
+ }
+
+ private function verifyDsseEnvelopeSignature(ReleaseMetadata $releaseMetadata, int $attestationIndex, Attestation $attestation): void
+ {
+ if (! extension_loaded('openssl')) {
+ throw FailedToVerifyRelease::fromNoOpenssl();
+ }
+
+ $publicKey = openssl_pkey_get_public($attestation->certificate);
+ Assert::isInstanceOf($publicKey, OpenSSLAsymmetricKey::class);
+
+ $preAuthenticationEncoding = sprintf(
+ 'DSSEv1 %d %s %d %s',
+ strlen($attestation->dsseEnvelopePayloadType),
+ $attestation->dsseEnvelopePayloadType,
+ strlen($attestation->dsseEnvelopePayload),
+ $attestation->dsseEnvelopePayload,
+ );
+
+ if (openssl_verify($preAuthenticationEncoding, $attestation->dsseEnvelopeSignature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
+ throw FailedToVerifyRelease::fromSignatureVerificationFailed($attestationIndex, $releaseMetadata);
+ }
+ }
+
+ private function assertDigestFromAttestationMatchesActual(BinaryFile $pharFilename, Attestation $attestation): void
+ {
+ /** @var mixed $decodedPayload */
+ $decodedPayload = json_decode($attestation->dsseEnvelopePayload, true);
+
+ if (
+ ! is_array($decodedPayload)
+ || ! array_key_exists('subject', $decodedPayload)
+ || ! is_array($decodedPayload['subject'])
+ || count($decodedPayload['subject']) !== 1
+ || ! array_key_exists(0, $decodedPayload['subject'])
+ || ! is_array($decodedPayload['subject'][0])
+ || ! array_key_exists('name', $decodedPayload['subject'][0])
+ || $decodedPayload['subject'][0]['name'] !== 'pie.phar'
+ || ! array_key_exists('digest', $decodedPayload['subject'][0])
+ || ! is_array($decodedPayload['subject'][0]['digest'])
+ || ! array_key_exists('sha256', $decodedPayload['subject'][0]['digest'])
+ || ! is_string($decodedPayload['subject'][0]['digest']['sha256'])
+ || $decodedPayload['subject'][0]['digest']['sha256'] === ''
+ ) {
+ throw FailedToVerifyRelease::fromInvalidSubjectDefinition();
+ }
+
+ $pharFilename->verifyAgainstOther(new BinaryFile(
+ $pharFilename->filePath,
+ $decodedPayload['subject'][0]['digest']['sha256'],
+ ));
+ }
+
+ /** @return non-empty-list */
+ private function downloadAttestations(ReleaseMetadata $releaseMetadata, BinaryFile $pharFilename): array
+ {
+ $attestationUrl = $this->githubApiBaseUrl . '/orgs/php/attestations/sha256:' . $pharFilename->checksum;
+
+ try {
+ $decodedJson = $this->httpDownloader->get(
+ $attestationUrl,
+ [
+ 'retry-auth-failure' => false,
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => $this->authHelper->addAuthenticationHeader([], $this->githubApiBaseUrl, $attestationUrl),
+ ],
+ ],
+ )->decodeJson();
+
+ Assert::isArray($decodedJson);
+ Assert::keyExists($decodedJson, 'attestations');
+ Assert::isNonEmptyList($decodedJson['attestations']);
+
+ return array_map(
+ static function (array $attestation): Attestation {
+ return Attestation::fromAttestationBundleWithDsseEnvelope($attestation);
+ },
+ $decodedJson['attestations'],
+ );
+ } catch (TransportException $transportException) {
+ if ($transportException->getStatusCode() === 404) {
+ throw FailedToVerifyRelease::fromMissingAttestation($releaseMetadata, $pharFilename);
+ }
+
+ throw $transportException;
+ }
+ }
+}
diff --git a/src/SelfManage/Verify/GithubCliAttestationVerification.php b/src/SelfManage/Verify/GithubCliAttestationVerification.php
new file mode 100644
index 00000000..901246d5
--- /dev/null
+++ b/src/SelfManage/Verify/GithubCliAttestationVerification.php
@@ -0,0 +1,52 @@
+executableFinder->find(self::GH_CLI_NAME);
+
+ if ($gh === null) {
+ throw GithubCliNotAvailable::fromExpectedGhToolName(self::GH_CLI_NAME);
+ }
+
+ $verificationCommand = [
+ $gh,
+ 'attestation',
+ 'verify',
+ '--owner=php',
+ $pharFilename->filePath,
+ ];
+
+ $output->writeln('Verifying using: ' . implode(' ', $verificationCommand), OutputInterface::VERBOSITY_VERBOSE);
+
+ try {
+ Process::run($verificationCommand, null, self::GH_VERIFICATION_TIMEOUT);
+ } catch (ProcessFailedException $processFailedException) {
+ throw FailedToVerifyRelease::fromGhCliFailure($releaseMetadata, $processFailedException);
+ }
+
+ $output->writeln('✅ Verified the new PIE version');
+ }
+}
diff --git a/src/SelfManage/Verify/GithubCliNotAvailable.php b/src/SelfManage/Verify/GithubCliNotAvailable.php
new file mode 100644
index 00000000..a71d952d
--- /dev/null
+++ b/src/SelfManage/Verify/GithubCliNotAvailable.php
@@ -0,0 +1,17 @@
+githubCliVerification->verify($releaseMetadata, $pharFilename, $output);
+ } catch (GithubCliNotAvailable $githubCliNotAvailable) {
+ $output->writeln($githubCliNotAvailable->getMessage(), OutputInterface::VERBOSITY_VERBOSE);
+
+ if (! extension_loaded('openssl')) {
+ throw FailedToVerifyRelease::fromNoOpenssl();
+ }
+
+ $this->fallbackVerification->verify($releaseMetadata, $pharFilename, $output);
+ }
+ }
+}
diff --git a/src/Util/PieVersion.php b/src/Util/PieVersion.php
index 5c44126c..56166b9c 100644
--- a/src/Util/PieVersion.php
+++ b/src/Util/PieVersion.php
@@ -17,6 +17,28 @@ final class PieVersion
*/
private const SYMFONY_MAGIC_CONST_UNKNOWN = 'UNKNOWN';
+ /**
+ * This value is replaced dynamically by Box with the real version when
+ * we build the PHAR. It is based on the Git tag and/or version
+ *
+ * It will be replaced with `2.0.0` on an exact tag match, or something
+ * like `2.0.0@e558e33` on a commit following a tag.
+ *
+ * When running not in a PHAR, this will not be replaced, so this
+ * method needs additional logic to determine the version.
+ *
+ * @link https://box-project.github.io/box/configuration/#pretty-git-tag-placeholder-git
+ */
+ private const PIE_VERSION = '@pie_version@';
+
+ public static function isPharBuild(): bool
+ {
+ /** @psalm-suppress RedundantCondition */
+
+ // phpcs:ignore Generic.Strings.UnnecessaryStringConcat.Found
+ return self::PIE_VERSION !== '@pie_version' . '@';
+ }
+
/**
* A static method to try to find the version of PIE you are currently
* running. If running in the PHAR built with Box, this should return a
@@ -26,44 +48,24 @@ final class PieVersion
*/
public static function get(): string
{
- /**
- * This value is replaced dynamically by Box with the real version when
- * we build the PHAR. It is based on the Git tag and/or version
- *
- * It will be replaced with `2.0.0` on an exact tag match, or something
- * like `2.0.0@e558e33` on a commit following a tag.
- *
- * When running not in a PHAR, this will not be replaced, so this
- * method needs additional logic to determine the version.
- *
- * @link https://box-project.github.io/box/configuration/#pretty-git-tag-placeholder-git
- */
- $pieVersion = '@pie_version@';
+ if (self::isPharBuild()) {
+ return self::PIE_VERSION;
+ }
+
+ if (! class_exists(InstalledVersions::class)) {
+ return self::SYMFONY_MAGIC_CONST_UNKNOWN;
+ }
/**
- * @psalm-suppress RedundantCondition
- * @noinspection PhpConditionAlreadyCheckedInspection
+ * This tries to determine the version based on Composer; if we are
+ * the root package (i.e. you're developing on it), this will most
+ * likely be something like `dev-main` (branch name).
*/
- // phpcs:ignore Generic.Strings.UnnecessaryStringConcat.Found
- if ($pieVersion === '@pie_version' . '@') {
- if (! class_exists(InstalledVersions::class)) {
- return self::SYMFONY_MAGIC_CONST_UNKNOWN;
- }
-
- /**
- * This tries to determine the version based on Composer; if we are
- * the root package (i.e. you're developing on it), this will most
- * likely be something like `dev-main` (branch name).
- */
- $installedVersion = InstalledVersions::getVersion(InstalledVersions::getRootPackage()['name']);
- if ($installedVersion === null) {
- return self::SYMFONY_MAGIC_CONST_UNKNOWN;
- }
-
- return $installedVersion;
+ $installedVersion = InstalledVersions::getVersion(InstalledVersions::getRootPackage()['name']);
+ if ($installedVersion === null) {
+ return self::SYMFONY_MAGIC_CONST_UNKNOWN;
}
- /** @psalm-suppress NoValue */
- return $pieVersion;
+ return $installedVersion;
}
}
diff --git a/test/assets/fake-gh-cli/happy.bat b/test/assets/fake-gh-cli/happy.bat
new file mode 100644
index 00000000..128249e7
--- /dev/null
+++ b/test/assets/fake-gh-cli/happy.bat
@@ -0,0 +1,2 @@
+echo "Pretending to be gh cli - happy path"
+exit /b 0
diff --git a/test/assets/fake-gh-cli/happy.sh b/test/assets/fake-gh-cli/happy.sh
new file mode 100755
index 00000000..d0f8e4d9
--- /dev/null
+++ b/test/assets/fake-gh-cli/happy.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+echo "Pretending to be gh cli - happy path"
+exit 0
diff --git a/test/assets/fake-gh-cli/unhappy.bat b/test/assets/fake-gh-cli/unhappy.bat
new file mode 100644
index 00000000..d3acb36e
--- /dev/null
+++ b/test/assets/fake-gh-cli/unhappy.bat
@@ -0,0 +1,2 @@
+echo "Pretending to be gh cli - unhappy path"
+exit /b 1
diff --git a/test/assets/fake-gh-cli/unhappy.sh b/test/assets/fake-gh-cli/unhappy.sh
new file mode 100755
index 00000000..df58d24d
--- /dev/null
+++ b/test/assets/fake-gh-cli/unhappy.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+echo "Pretending to be gh cli - unhappy path"
+exit 1
diff --git a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php
new file mode 100644
index 00000000..73f245d6
--- /dev/null
+++ b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php
@@ -0,0 +1,117 @@
+createMock(HttpDownloader::class);
+ $authHelper = $this->createMock(AuthHelper::class);
+
+ $url = self::TEST_GITHUB_URL . '/repos/php/pie/releases/latest';
+ $authHelper
+ ->method('addAuthenticationHeader')
+ ->willReturn(['Authorization: Bearer fake-token']);
+ $httpDownloader->expects(self::once())
+ ->method('get')
+ ->with(
+ $url,
+ [
+ 'retry-auth-failure' => false,
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => ['Authorization: Bearer fake-token'],
+ ],
+ ],
+ )
+ ->willReturn(
+ new Response(
+ ['url' => $url],
+ 200,
+ [],
+ json_encode([
+ 'tag_name' => '1.2.3',
+ 'assets' => [
+ [
+ 'name' => 'not-pie.phar',
+ 'browser_download_url' => self::TEST_GITHUB_URL . '/do/not/download/this',
+ ],
+ [
+ 'name' => 'pie.phar',
+ 'browser_download_url' => self::TEST_GITHUB_URL . '/path/to/pie.phar',
+ ],
+ ],
+ ]),
+ ),
+ );
+
+ $fetch = new FetchPieReleaseFromGitHub(self::TEST_GITHUB_URL, $httpDownloader, $authHelper);
+
+ $latestRelease = $fetch->latestReleaseMetadata();
+
+ self::assertSame('1.2.3', $latestRelease->tag);
+ self::assertSame(self::TEST_GITHUB_URL . '/path/to/pie.phar', $latestRelease->downloadUrl);
+ }
+
+ public function testDownloadContent(): void
+ {
+ $url = self::TEST_GITHUB_URL . '/path/to/pie.phar';
+ $pharContent = uniqid('pharContent', true);
+ $expectedDigest = hash('sha256', $pharContent);
+
+ $latestRelease = new ReleaseMetadata('1.2.3', $url);
+
+ $httpDownloader = $this->createMock(HttpDownloader::class);
+ $authHelper = $this->createMock(AuthHelper::class);
+
+ $authHelper
+ ->method('addAuthenticationHeader')
+ ->willReturn(['Authorization: Bearer fake-token']);
+ $httpDownloader->expects(self::once())
+ ->method('get')
+ ->with(
+ $url,
+ [
+ 'retry-auth-failure' => false,
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => ['Authorization: Bearer fake-token'],
+ ],
+ ],
+ )
+ ->willReturn(
+ new Response(
+ ['url' => $url],
+ 200,
+ [],
+ $pharContent,
+ ),
+ );
+
+ $fetch = new FetchPieReleaseFromGitHub(self::TEST_GITHUB_URL, $httpDownloader, $authHelper);
+
+ $file = $fetch->downloadContent($latestRelease);
+
+ self::assertSame($pharContent, file_get_contents($file->filePath));
+ self::assertSame($expectedDigest, $file->checksum);
+ }
+}
diff --git a/test/unit/SelfManage/Verify/AttestationTest.php b/test/unit/SelfManage/Verify/AttestationTest.php
new file mode 100644
index 00000000..08b81dc3
--- /dev/null
+++ b/test/unit/SelfManage/Verify/AttestationTest.php
@@ -0,0 +1,176 @@
+ [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ]);
+
+ self::assertSame(
+ "-----BEGIN CERTIFICATE-----\n"
+ . "some great certificate content. some great certificate content.\n"
+ . "some great certificate content.\n"
+ . "-----END CERTIFICATE-----\n",
+ $attestation->certificate,
+ );
+ self::assertSame('this is the amazing payload', $attestation->dsseEnvelopePayload);
+ self::assertSame('this is the payload type', $attestation->dsseEnvelopePayloadType);
+ self::assertSame('signature number one!', $attestation->dsseEnvelopeSignature);
+ }
+
+ /**
+ * @return list>>
+ *
+ * @psalm-suppress PossiblyUnusedMethod https://github.com/psalm/psalm-plugin-phpunit/issues/131
+ */
+ public static function invalidBundleProvider(): array
+ {
+ return [
+ [
+ [],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => ''],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => [],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => '',
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'payloadType' => '',
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'signatures' => [['sig' => base64_encode('signature number one!')]],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [['sig' => '']],
+ ],
+ ],
+ ],
+ ],
+ [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => 'some great certificate content. some great certificate content. some great certificate content.'],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode('this is the amazing payload'),
+ 'payloadType' => 'this is the payload type',
+ 'signatures' => [],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /** @param array $invalidBundle */
+ #[DataProvider('invalidBundleProvider')]
+ public function testFromAttestationWithInvalidBundles(array $invalidBundle): void
+ {
+ self::expectException(InvalidArgumentException::class);
+ Attestation::fromAttestationBundleWithDsseEnvelope($invalidBundle);
+ }
+}
diff --git a/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php
new file mode 100644
index 00000000..d286463f
--- /dev/null
+++ b/test/unit/SelfManage/Verify/FallbackVerificationUsingOpenSslTest.php
@@ -0,0 +1,279 @@
+release = new ReleaseMetadata('1.2.3', self::TEST_GITHUB_URL . '/pie.phar');
+ $this->downloadedPhar = new BinaryFile('/path/to/pie.phar', 'fake-checksum');
+
+ $this->httpDownloader = $this->createMock(HttpDownloader::class);
+ $this->authHelper = $this->createMock(AuthHelper::class);
+ $this->output = new BufferedOutput();
+
+ $this->trustedRootFilePath = tempnam(sys_get_temp_dir(), 'pie_test_trusted_root_file_path');
+
+ $this->verifier = new FallbackVerificationUsingOpenSsl($this->trustedRootFilePath, self::TEST_GITHUB_URL, $this->httpDownloader, $this->authHelper);
+ }
+
+ /** @return array{0: string, 1: string} */
+ private function prepareCertificateAndSignature(string $dsseEnvelopePayload): array
+ {
+ $caPrivateKey = openssl_pkey_new();
+ $caCsr = openssl_csr_new(['CN' => 'pie-test-ca'], $caPrivateKey);
+ $caCert = openssl_csr_sign($caCsr, null, $caPrivateKey, 1);
+ openssl_x509_export($caCert, $caPemCertificate);
+
+ file_put_contents($this->trustedRootFilePath, json_encode([
+ 'mediaType' => 'application/vnd.dev.sigstore.trustedroot+json;version=0.1',
+ 'certificateAuthorities' => [
+ [
+ 'certChain' => [
+ 'certificates' => [
+ [
+ 'rawBytes' => trim(str_replace('-----BEGIN CERTIFICATE-----', '', str_replace('-----END CERTIFICATE-----', '', $caPemCertificate))),
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]));
+
+ $tempOpensslConfig = tempnam(sys_get_temp_dir(), 'pie_openssl_test_config');
+ file_put_contents($tempOpensslConfig, <<<'EOF'
+
+[ req ]
+default_bits = 2048
+prompt = no
+encrypt_key = no
+default_md = sha1
+distinguished_name = dn
+x509_extensions = v3_req
+
+[ dn ]
+
+[ v3_req ]
+1.3.6.1.4.1.57264.1.8 = ASN1:UTF8String:https://token.actions.githubusercontent.com
+1.3.6.1.4.1.57264.1.12 = ASN1:UTF8String:https://github.com/php/pie
+1.3.6.1.4.1.57264.1.16 = ASN1:UTF8String:https://github.com/php
+EOF);
+ $privateKey = openssl_pkey_new();
+ $csr = openssl_csr_new(['commonName' => 'pie-test'], $privateKey, ['config' => $tempOpensslConfig]);
+ $certificate = openssl_csr_sign($csr, $caCert, $caPrivateKey, 1, [
+ 'config' => $tempOpensslConfig,
+ 'x509_extensions' => 'v3_req',
+ ]);
+ openssl_x509_export($certificate, $pemCertificate);
+
+ openssl_sign(
+ sprintf(
+ 'DSSEv1 %d %s %d %s',
+ strlen(self::DSSE_PAYLOAD_TYPE),
+ self::DSSE_PAYLOAD_TYPE,
+ strlen($dsseEnvelopePayload),
+ $dsseEnvelopePayload,
+ ),
+ $signature,
+ $privateKey,
+ OPENSSL_ALGO_SHA256,
+ );
+
+ return [$pemCertificate, $signature];
+ }
+
+ private function mockAttestationResponse(string $digestInUrl, string $dsseEnvelopePayload, string $signature, string $pemCertificate): void
+ {
+ $url = self::TEST_GITHUB_URL . '/orgs/php/attestations/sha256:' . $digestInUrl;
+ $this->authHelper
+ ->method('addAuthenticationHeader')
+ ->willReturn(['Authorization: Bearer fake-token']);
+ $this->httpDownloader->expects(self::once())
+ ->method('get')
+ ->with(
+ $url,
+ [
+ 'retry-auth-failure' => false,
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => ['Authorization: Bearer fake-token'],
+ ],
+ ],
+ )
+ ->willReturn(
+ new Response(
+ ['url' => $url],
+ 200,
+ [],
+ json_encode([
+ 'attestations' => [
+ [
+ 'bundle' => [
+ 'verificationMaterial' => [
+ 'certificate' => ['rawBytes' => trim(str_replace('-----BEGIN CERTIFICATE-----', '', str_replace('-----END CERTIFICATE-----', '', $pemCertificate)))],
+ ],
+ 'dsseEnvelope' => [
+ 'payload' => base64_encode($dsseEnvelopePayload),
+ 'payloadType' => self::DSSE_PAYLOAD_TYPE,
+ 'signatures' => [['sig' => base64_encode($signature)]],
+ ],
+ ],
+ ],
+ ],
+ ]),
+ ),
+ );
+ }
+
+ public function testSuccessfulVerify(): void
+ {
+ if (! extension_loaded('openssl')) {
+ self::markTestSkipped('Cannot run tests without openssl extension');
+ }
+
+ $dsseEnvelopePayload = json_encode([
+ 'subject' => [
+ [
+ 'name' => 'pie.phar',
+ 'digest' => ['sha256' => $this->downloadedPhar->checksum],
+ ],
+ ],
+ ]);
+
+ [$pemCertificate, $signature] = $this->prepareCertificateAndSignature($dsseEnvelopePayload);
+
+ $this->mockAttestationResponse($this->downloadedPhar->checksum, $dsseEnvelopePayload, $signature, $pemCertificate);
+
+ $this->verifier->verify($this->release, $this->downloadedPhar, $this->output);
+
+ self::assertStringContainsString('Verified the new PIE version (using fallback verification)', $this->output->fetch());
+ }
+
+ public function testFailedToVerifyBecauseDigestMismatch(): void
+ {
+ if (! extension_loaded('openssl')) {
+ self::markTestSkipped('Cannot run tests without openssl extension');
+ }
+
+ $dsseEnvelopePayload = json_encode([
+ 'subject' => [
+ [
+ 'name' => 'pie.phar',
+ 'digest' => ['sha256' => 'different-checksum'],
+ ],
+ ],
+ ]);
+
+ [$pemCertificate, $signature] = $this->prepareCertificateAndSignature($dsseEnvelopePayload);
+
+ $this->mockAttestationResponse($this->downloadedPhar->checksum, $dsseEnvelopePayload, $signature, $pemCertificate);
+
+ $this->expectException(BinaryFileFailedVerification::class);
+ $this->verifier->verify($this->release, $this->downloadedPhar, $this->output);
+ }
+
+ public function testFailedToVerifyBecauseSignatureVerificationFailed(): void
+ {
+ if (! extension_loaded('openssl')) {
+ self::markTestSkipped('Cannot run tests without openssl extension');
+ }
+
+ $dsseEnvelopePayload = json_encode([
+ 'subject' => [
+ [
+ 'name' => 'pie.phar',
+ 'digest' => ['sha256' => $this->downloadedPhar->checksum],
+ ],
+ ],
+ ]);
+
+ [$pemCertificate, $signature] = $this->prepareCertificateAndSignature($dsseEnvelopePayload);
+
+ $this->mockAttestationResponse(
+ $this->downloadedPhar->checksum,
+ json_encode([
+ 'subject' => [
+ [
+ 'name' => 'pie.phar',
+ 'digest' => ['sha256' => $this->downloadedPhar->checksum],
+ 'i-tampered-with-this-payload-hahahaha' => true,
+ ],
+ ],
+ ]),
+ $signature,
+ $pemCertificate,
+ );
+
+ $this->expectException(FailedToVerifyRelease::class);
+ $this->verifier->verify($this->release, $this->downloadedPhar, $this->output);
+ }
+
+ public function testFailedToVerifyBecauseDigestNotFoundOnGitHub(): void
+ {
+ if (! extension_loaded('openssl')) {
+ self::markTestSkipped('Cannot run tests without openssl extension');
+ }
+
+ $transportException = new TransportException('404 Not Found');
+ $transportException->setStatusCode(404);
+
+ $this->authHelper
+ ->method('addAuthenticationHeader')
+ ->willReturn(['Authorization: Bearer fake-token']);
+ $this->httpDownloader->expects(self::once())
+ ->method('get')
+ ->willThrowException($transportException);
+
+ $this->expectException(FailedToVerifyRelease::class);
+ $this->verifier->verify($this->release, $this->downloadedPhar, $this->output);
+ }
+}
diff --git a/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php
new file mode 100644
index 00000000..ad9863d4
--- /dev/null
+++ b/test/unit/SelfManage/Verify/GithubCliAttestationVerificationTest.php
@@ -0,0 +1,71 @@
+executableFinder = $this->createMock(ExecutableFinder::class);
+ $this->output = new BufferedOutput();
+
+ $this->verifier = new GithubCliAttestationVerification($this->executableFinder);
+ }
+
+ public function testPassingVerification(): void
+ {
+ $this->executableFinder
+ ->method('find')
+ ->willReturn(Platform::isWindows() ? self::FAKE_GH_CLI_HAPPY_BAT : self::FAKE_GH_CLI_HAPPY_SH);
+
+ $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output);
+
+ self::assertStringContainsString('Verified the new PIE version', $this->output->fetch());
+ }
+
+ public function testCannotFindGhCli(): void
+ {
+ $this->executableFinder
+ ->method('find')
+ ->willReturn(null);
+
+ $this->expectException(GithubCliNotAvailable::class);
+ $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output);
+ }
+
+ public function testFailingVerification(): void
+ {
+ $this->executableFinder
+ ->method('find')
+ ->willReturn(Platform::isWindows() ? self::FAKE_GH_CLI_UNHAPPY_BAT : self::FAKE_GH_CLI_UNHAPPY_SH);
+
+ $this->expectException(FailedToVerifyRelease::class);
+ $this->verifier->verify(new ReleaseMetadata('1.2.3', 'https://path/to/download'), new BinaryFile('/path/to/phar', 'some-checksum'), $this->output);
+ }
+}