From 2d01db754670f0cf2f948d8983e4167e0df004f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 17:49:34 +0200 Subject: [PATCH 1/7] fix: publish and pin binary checksums, verify existing binaries (#63) Checksum verification was fail-closed for downloads but nothing was ever pinned, so every native install was refused, and a binary already on disk was trusted without a checksum. - composer.json pins the published SHA-256 digests of 0.5.0 and 0.5.2 under extra.scanmephp.checksums, and the plugin reads them from the installed package (as issue #48 describes: checksums shipped with the released package), with the root project's own pins taking precedence. - ChecksumManager::existingBinaryIsValid() rejects a binary it cannot verify instead of accepting it, so an unknown file is re-downloaded through the fail-closed download path; the installer says whether it failed the digest or had no digest to check. - The release workflow publishes checksums.txt (sha256sum format) with every release, and docs/release-workflow.md documents pinning the digests of a new release after it is built. --- .github/workflows/release-build.yml | 10 +++ CHANGELOG.md | 17 ++++ README.md | 35 ++++++++ composer.json | 46 ++++++++++- docs/release-workflow.md | 25 ++++-- src/ChecksumManager.php | 77 +++++++++++++----- src/Composer/Plugin.php | 38 +++++++-- tests/ChecksumManagerTest.php | 120 ++++++++++++++++++++++++++-- tests/Composer/PluginTest.php | 57 +++++++++++-- 9 files changed, 379 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 4d1ad2d..6eabf45 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -226,6 +226,16 @@ jobs: - name: List artifacts run: ls -la artifacts/ + # sha256sum writes the standard " " format, so a consumer can + # verify a download with `sha256sum -c checksums.txt`. The same digests are + # pinned in composer.json (extra.scanmephp.checksums) so the installer can + # verify a download before it writes anything to disk. + - name: Compute SHA-256 checksums + run: | + set -euo pipefail + (cd artifacts && sha256sum * > checksums.txt) + cat artifacts/checksums.txt + - name: Extract release notes from CHANGELOG.md # Reads CHANGELOG.md from the tagged commit: merge the `## [X.Y.Z]` section before tagging. run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 247715d..f0573d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `bin/worktree-setup.sh`. - Dependabot for Composer and GitHub Actions, and a pull request template. - CI jobs `changes`, `docs` and `ci-ok`; documentation-only changes skip the heavy jobs. +- Every release now publishes a `checksums.txt` asset with the SHA-256 of all its binaries, + in `sha256sum` format, so a manual download can be verified with `sha256sum -c checksums.txt`. +- `composer.json` pins the SHA-256 of the published binaries of `0.5.0` and `0.5.2` + (`extra.scanmephp.checksums`), and the installer reads those pins from the installed + package, so a verified native install no longer needs any action from the consumer. + `docs/release-workflow.md` documents how to add the checksums of a new release. ### Changed @@ -51,6 +57,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a single FFI instance (#201). This also skips re-parsing the header on every `NativeEncoder` call without the extension. CI's extension-loaded test pass is blocking again. +### Security + +- `ChecksumManager::existingBinaryIsValid()` no longer trusts a binary that is already on disk + when no checksum is pinned for it: such a file cannot be verified, so it is rejected and + re-downloaded through the verified (fail-closed) path, which refuses it too instead of + accepting an unknown binary (#63). +- The Composer plugin reads `extra.scanmephp.checksums` from the installed package's + `composer.json` (the checksums of that release) as well as from the root project, which takes + precedence. A pinned checksum is required for the version and binary in question, so the + native binaries are installed again instead of every install being refused (#63). + ## [0.5.2] - 2026-08-26 v0.5.1 has no binaries behind it: every extension build failed, so `Create diff --git a/README.md b/README.md index fbeb332..68cf483 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,41 @@ When you install or update the package via Composer, the library will automatica If no binary matches your platform — arm64 Linux, an unusual PHP build — the extension can be compiled on the spot with [PIE](https://github.com/php/pie): `pie install crazy-goat/qrcode-ext`. +#### Checksum Verification + +Every downloaded binary is verified before it is written to disk, and a binary that is already in +`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is re-verified on every install. Verification is +fail-closed: without a SHA-256 checksum for the requested version and binary the plugin refuses the +file and falls back to the pure PHP encoder, which needs no native code. + +Checksums are read from `extra.scanmephp.checksums`, first from the root project's `composer.json` +and then from the installed package, which ships the checksums of its own release. Every release +also publishes `checksums.txt`, so a manual download can be verified too: + +```bash +gh release download vX.Y.Z -p 'checksums.txt' +sha256sum -c checksums.txt +``` + +If your version has no published checksum yet (for example a version released before this +mechanism existed, or a PHP build the release does not cover), the plugin tells you which binary it +wanted. Pin the digest yourself — take it from the release's `checksums.txt` — and the verified +install works again: + +```json +{ + "extra": { + "scanmephp": { + "checksums": { + "0.5.2": { + "libscanme_qr-linux-glibc-x86_64.so": "" + } + } + } + } +} +``` + ### PHP Extension Installation (Recommended) The PHP extension provides the best performance. The Composer plugin will attempt to download it automatically. diff --git a/composer.json b/composer.json index ee96347..1bfc375 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,51 @@ } }, "extra": { - "class": "CrazyGoat\\ScanMePHP\\Composer\\Plugin" + "class": "CrazyGoat\\ScanMePHP\\Composer\\Plugin", + "scanmephp": { + "checksums": { + "0.5.0": { + "libscanme_qr-linux-glibc-x86_64.so": "05e5b0b22df2be43ee39bc3fd1732d6c5c8d9b8ac6dd0520e524bc13ccd72030", + "libscanme_qr-linux-musl-x86_64.so": "c6c1c2337cd4f980d34d75729185d9b372426b62ff0d21b40006c024024c7879", + "libscanme_qr-macos-arm64.dylib": "a4727b4ec4273cfdde1e983d0afffc742458ff5ac8015d11dcd5bbf5682157ad", + "libscanme_qr-macos-x86_64.dylib": "0bd9cf89b55d3afea2177e6a55e923d25f0c11ed5fadc622b3d0d1f9507458c0", + "php-ext-linux-glibc-x86_64-php8.1.so": "e65e17bbed60b0fcbf1509534586c234c699d18101e6be3ac5afd6473a233ea2", + "php-ext-linux-glibc-x86_64-php8.2.so": "79392b4145755d2242dd8ef5c3dab8108d4fa7574765b05667325fd595d05609", + "php-ext-linux-glibc-x86_64-php8.3.so": "8a8d21f47909126c06b8148b7aaef703882bef33b9c63b977197b1c58a7feee2", + "php-ext-linux-glibc-x86_64-php8.4.so": "5147d4e74ca49c8f6313eb536330c5c2a2962e789e8709fbe51d00308cf11ea3", + "php-ext-linux-musl-x86_64-php8.1.so": "5aaab1d8cec0249e97df6d03b08bde138ff588a7dac181cae1f26d29a7c2cbb1", + "php-ext-linux-musl-x86_64-php8.2.so": "0bfb13f08955dea97ccd030e4d5ab605565c373900e69bd8c0caf34c7817f56d", + "php-ext-linux-musl-x86_64-php8.3.so": "77e6e6d748c0018379fde7fb90472faff9921c1f5c2b2ea3f9a30e3441616349", + "php-ext-linux-musl-x86_64-php8.4.so": "dd1113721679098ab94cfb8caaff98d4e18b242dbe205427727d459d0596b019", + "php-ext-macos-arm64-php8.1.so": "1705f7c7f29a67ece365e73a1363640f971f8c47ae55bc8c39604a68e93682fe", + "php-ext-macos-arm64-php8.2.so": "241ef01352799906fd3b1088ba17d31f20d3c2ceb105a6c6559db98a1e05d4bd", + "php-ext-macos-arm64-php8.3.so": "c53d9c564d56656acb5fb477d0018c5beb44139f8506cd26f8e3bd575573c1ba", + "php-ext-macos-arm64-php8.4.so": "2cf383f3e71ddddc3761728c0614e9e13b71f7246f51d80bb0da09c90412ad2c", + "php-ext-macos-x86_64-php8.1.so": "89972ca17a3459835faefac457531c24763ea04cb37e4311b6b500c1c7603987", + "php-ext-macos-x86_64-php8.2.so": "fabcd2badab714d621aefb085fdc8cef941d0d30dd51221e8d64054a64cb4351", + "php-ext-macos-x86_64-php8.3.so": "1e84c00a4d684e6551e4d73ccf4c7c307ba3684efec80665560476a5edb7219a", + "php-ext-macos-x86_64-php8.4.so": "3ce80a902be9acccab7bd05cea60f651a93d06692f9304b2e1a8d81d70c0742e" + }, + "0.5.2": { + "libscanme_qr-linux-glibc-x86_64.so": "05e5b0b22df2be43ee39bc3fd1732d6c5c8d9b8ac6dd0520e524bc13ccd72030", + "libscanme_qr-linux-musl-x86_64.so": "c6c1c2337cd4f980d34d75729185d9b372426b62ff0d21b40006c024024c7879", + "libscanme_qr-macos-arm64.dylib": "a4727b4ec4273cfdde1e983d0afffc742458ff5ac8015d11dcd5bbf5682157ad", + "libscanme_qr-macos-x86_64.dylib": "0bd9cf89b55d3afea2177e6a55e923d25f0c11ed5fadc622b3d0d1f9507458c0", + "php-ext-linux-glibc-x86_64-php8.2.so": "d0c354ad01f62e954791b577e56cf1c04814551d5f2f74b8e004dec5aec5942f", + "php-ext-linux-glibc-x86_64-php8.3.so": "7dd53b8b2a12de91f7fddfb6d6cc09d17891834d2d314875682e1309d8313eaf", + "php-ext-linux-glibc-x86_64-php8.4.so": "db39378c1bce1f2392fa67d1f6a5925d675e87634106fa416044a5f93b6b8f31", + "php-ext-linux-musl-x86_64-php8.2.so": "aa476d2922abf3549242d32da09bb59f359ca5801e98779791ae38ca31f62eb3", + "php-ext-linux-musl-x86_64-php8.3.so": "ec8d12726ee50bef4f3f389c6d6d65e5202d1670d9416214ca3b467a1d506c97", + "php-ext-linux-musl-x86_64-php8.4.so": "6fd015b9c9eaf74362b784ac6cd94bd59cd2be6fed459d6480f9ef78cc1e69ea", + "php-ext-macos-arm64-php8.2.so": "02af5fd671401b7cd58bc2120c3f1196d47b0a9fefa25f898eae5e48bfa62b58", + "php-ext-macos-arm64-php8.3.so": "10b8de46497c25e6673661f21964b8e27434c35a73b679ea25a5ffe4534ad783", + "php-ext-macos-arm64-php8.4.so": "4858f86d2db56778ccf6a53d5083ca2cfffd925404b25acfc4b6934356471d48", + "php-ext-macos-x86_64-php8.2.so": "9985f28ffd4965a153cfe289535a14066b94012ad97731ba1a0449df151d0d21", + "php-ext-macos-x86_64-php8.3.so": "aef54cba731dc7b2e6f34dc408b8ccfb28e9a034d0bcdfc80e9b475fab7fb8b9", + "php-ext-macos-x86_64-php8.4.so": "bd3d775e060ceac3c863a3c431827e42af59f74f1cb745fdf1c4c813bf49dc58" + } + } + } }, "scripts": { "test": "phpunit", diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 945de19..a80b563 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -71,13 +71,14 @@ The workflow builds the binaries and attaches them to the release: | `libscanme_qr-macos-x86_64.dylib`, `libscanme_qr-macos-arm64.dylib` | FFI library, macOS | | `php-ext-linux-{glibc,musl}-x86_64-php{8.2,8.3,8.4}.so` | PHP extension, Linux (6 files) | | `php-ext-macos-{x86_64,arm64}-php{8.2,8.3,8.4}.so` | PHP extension, macOS (6 files) | +| `checksums.txt` | SHA-256 of every asset above, in `sha256sum` format | The `release` job runs only when every build job succeeded. It extracts the notes of the matching `CHANGELOG.md` section (cut at 120000 characters, below the GitHub limit of -125000) and runs `gh release create --verify-tag` with the assets. It fails when the -section is missing or empty. Tags with a `-` (for example `v0.6.0-rc.1`) become -pre-releases. If the release already exists (also as a draft), the workflow uploads the -assets to it and publishes it. +125000), computes the SHA-256 of every artifact and runs `gh release create --verify-tag` +with the assets. It fails when the section is missing or empty. Tags with a `-` (for +example `v0.6.0-rc.1`) become pre-releases. If the release already exists (also as a draft), +the workflow uploads the assets to it and publishes it. Running the workflow by hand (`workflow_dispatch`) from a branch builds every binary and publishes nothing. Do this before tagging when you touched the toolchain, `clib/` or `php-ext/`. @@ -109,6 +110,19 @@ Make sure the next milestone `vX.Y.(Z+1)` (or the next minor) exists. bash bin/build-ext-mirror.sh --push vX.Y.Z ``` +- Pin the checksums of the new binaries in `extra.scanmephp.checksums` of `composer.json` + and merge the result. The digests are published in the release's `checksums.txt`: + + ```bash + gh release download vX.Y.Z -p 'checksums.txt' -D /tmp/vX.Y.Z + grep -E ' (libscanme_qr|php-ext)' /tmp/vX.Y.Z/checksums.txt + ``` + + Without this the installer of `vX.Y.Z` has no checksum for its own binaries and refuses the + download (fail-closed), so the pure PHP encoder is used. This is a chicken-and-egg problem: + the checksums exist only after the build, so the entry lands in the *next* release; pin them + as soon as possible and keep the previous versions' entries. + - Check that install instructions work with the new version (`composer require crazy-goat/scanmephp`, `pie install crazy-goat/qrcode-ext`). - If something is wrong, do not move the tag. Fix forward with a patch release. @@ -120,6 +134,7 @@ Make sure the next milestone `vX.Y.(Z+1)` (or the next minor) exists. - [ ] Release PR merged - [ ] `PHP_SCANME_QR_VERSION` bumped - [ ] Annotated tag `vX.Y.Z` pushed -- [ ] GitHub Release exists with the CHANGELOG notes and all binaries +- [ ] GitHub Release exists with the CHANGELOG notes, all binaries and `checksums.txt` +- [ ] `composer.json` pins the checksums of the new binaries (see [7. After the release](#7-after-the-release)) - [ ] `qrcode-ext` mirror published - [ ] Milestone closed, next milestone exists diff --git a/src/ChecksumManager.php b/src/ChecksumManager.php index 9813cba..f39b6b7 100644 --- a/src/ChecksumManager.php +++ b/src/ChecksumManager.php @@ -6,43 +6,77 @@ class ChecksumManager { - private ?array $checksums = null; - - public function __construct(private readonly string $projectRoot) + /** + * Checksum maps in lookup order. The first source with an entry for the + * requested version and binary wins: + * + * - the root project may pin checksums itself (extra.scanmephp.checksums), + * - the installed package ships the checksums of its own release. + * + * @var list>> + */ + private array $sources = []; + + /** + * @param string $projectRoot directory of the root project (its composer.json may pin checksums) + * @param string|null $packageRoot directory of the installed package (its composer.json ships the + * checksums of the release); omit it when there is no package + */ + public function __construct(string $projectRoot, ?string $packageRoot = null) { - $this->loadChecksums(); + $this->addSource($projectRoot); + + if ($packageRoot !== null) { + $this->addSource($packageRoot); + } } - private function loadChecksums(): void + private function addSource(string $root): void { - $composerJsonPath = $this->projectRoot . '/composer.json'; + $composerJsonPath = $root . '/composer.json'; + + if (!is_file($composerJsonPath)) { + return; + } + + $contents = file_get_contents($composerJsonPath); + + if ($contents === false) { + return; + } - if (!file_exists($composerJsonPath)) { + $composer = json_decode($contents, true); + + if (json_last_error() !== JSON_ERROR_NONE || !is_array($composer)) { return; } - $composer = json_decode(file_get_contents($composerJsonPath), true); + $checksums = $composer['extra']['scanmephp']['checksums'] ?? null; - if (json_last_error() !== JSON_ERROR_NONE) { + if (!is_array($checksums)) { return; } - $this->checksums = $composer['extra']['scanmephp']['checksums'] ?? null; + $this->sources[] = $checksums; } public function getChecksum(string $version, string $binaryName): ?string { - if ($this->checksums === null) { - return null; - } - // Accept both '0.4.4' and 'v0.4.4' as composer.json version keys. $unprefixed = str_starts_with($version, 'v') ? substr($version, 1) : $version; - return $this->checksums[$version][$binaryName] - ?? $this->checksums['v' . $unprefixed][$binaryName] - ?? $this->checksums[$unprefixed][$binaryName] - ?? null; + foreach ($this->sources as $checksums) { + $checksum = $checksums[$version][$binaryName] + ?? $checksums['v' . $unprefixed][$binaryName] + ?? $checksums[$unprefixed][$binaryName] + ?? null; + + if (is_string($checksum) && $checksum !== '') { + return $checksum; + } + } + + return null; } public function hasChecksum(string $version, string $binaryName): bool @@ -54,10 +88,11 @@ public function existingBinaryIsValid(string $version, string $binaryName, strin { $checksum = $this->getChecksum($version, $binaryName); - // No pinned checksum: keep the legacy behavior of accepting whatever is - // already on disk (no regression for consumers without pinned checksums). + // Fail-closed: without a pinned checksum an on-disk binary cannot be + // verified, so it is not accepted. The caller re-downloads it through + // the verified download path, which refuses without a checksum too. if ($checksum === null) { - return true; + return false; } // Fail-closed: a file that cannot be hashed (missing/unreadable) is invalid. diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index 2f4f2f9..60126e6 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -108,8 +108,9 @@ private function installBinaries(\Composer\Package\PackageInterface $package): v return; } - // Checksums are pinned by the root project's composer.json (extra.scanmephp.checksums) - $checksumManager = new ChecksumManager($this->getProjectRoot()); + // The installed package ships the checksums of its own release in its composer.json + // (extra.scanmephp.checksums); the root project may pin its own, which wins. + $checksumManager = new ChecksumManager($this->getProjectRoot(), $installPath); // Try to install PHP extension first (preferred for performance) $extInstalled = $this->installExtensionBinary($installPath, $os, $variant, $arch, $version, $checksumManager); @@ -154,9 +155,13 @@ private function installExtensionBinary(string $installPath, string $os, ?string return true; } - // Pinned checksum does not match the on-disk file: remove it and - // re-download through the verified (fail-closed) download path. - $this->io->write('⚠️ Existing extension binary failed SHA-256 verification. Re-downloading the verified binary.'); + // The on-disk binary cannot be verified: remove it and re-download + // through the verified (fail-closed) download path. + $this->io->write('⚠️ Existing extension binary ' . $this->verificationFailure( + $version, + $binaryName, + $checksumManager + ) . '. Re-downloading the verified binary.'); @unlink($targetFile); } @@ -225,9 +230,13 @@ private function installFfiBinary(string $installPath, string $os, ?string $vari return; } - // Pinned checksum does not match the on-disk file: remove it and - // re-download through the verified (fail-closed) download path. - $this->io->write('⚠️ Existing FFI library failed SHA-256 verification. Re-downloading the verified library.'); + // The on-disk binary cannot be verified: remove it and re-download + // through the verified (fail-closed) download path. + $this->io->write('⚠️ Existing FFI library ' . $this->verificationFailure( + $version, + $binaryName, + $checksumManager + ) . '. Re-downloading the verified library.'); @unlink($targetFile); } @@ -264,6 +273,19 @@ protected function createDownloader(string $binaryPath, string $version, Checksu return new BinaryDownloader(self::PACKAGE_NAME, $version, $binaryPath, $checksumManager); } + /** + * Why an already-present binary was rejected: a pinned checksum that does + * not match, or no checksum at all to check it against. + */ + private function verificationFailure(string $version, string $binaryName, ChecksumManager $checksumManager): string + { + if ($checksumManager->hasChecksum($version, $binaryName)) { + return 'failed SHA-256 verification'; + } + + return 'cannot be verified: no SHA-256 checksum is pinned for ' . $binaryName; + } + private function getProjectRoot(): string { return dirname((string) $this->composer->getConfig()->get('vendor-dir')); diff --git a/tests/ChecksumManagerTest.php b/tests/ChecksumManagerTest.php index c7dfa36..d4ffeef 100644 --- a/tests/ChecksumManagerTest.php +++ b/tests/ChecksumManagerTest.php @@ -188,18 +188,19 @@ public function testExistingBinaryIsInvalidWhenChecksumMismatches(): void } } - public function testExistingBinaryIsValidWhenNoChecksumConfigured(): void + public function testExistingBinaryIsInvalidWhenNoChecksumConfigured(): void { $tempDir = $this->createChecksumFixture(null); try { - // No pinned checksum: legacy behavior is preserved (existing file accepted) + // No pinned checksum: the file on disk cannot be verified at all, so + // it is not accepted (fail-closed) and the caller re-downloads it. $binaryPath = $tempDir . '/libscanme_qr-linux-glibc-x86_64.so'; file_put_contents($binaryPath, 'unverified-binary-content'); $manager = new ChecksumManager($tempDir); - $this->assertTrue( + $this->assertFalse( $manager->existingBinaryIsValid('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) ); } finally { @@ -207,6 +208,115 @@ public function testExistingBinaryIsValidWhenNoChecksumConfigured(): void } } + public function testExistingBinaryIsInvalidWhenOnlyThePackageShipsNoChecksum(): void + { + $tempDir = $this->createChecksumFixture(null); + $packageDir = $this->createChecksumFixture(null, 'crazy-goat/scanmephp'); + + try { + $binaryPath = $tempDir . '/libscanme_qr-linux-glibc-x86_64.so'; + file_put_contents($binaryPath, 'unverified-binary-content'); + + $manager = new ChecksumManager($tempDir, $packageDir); + + $this->assertFalse( + $manager->existingBinaryIsValid('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) + ); + } finally { + $this->cleanupFixture($tempDir, ['libscanme_qr-linux-glibc-x86_64.so']); + $this->cleanupFixture($packageDir); + } + } + + public function testReadsChecksumsShippedWithTheInstalledPackage(): void + { + $tempDir = $this->createChecksumFixture(null); + $packageDir = $this->createChecksumFixture([ + '0.5.2' => [ + 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'shipped-binary-content'), + ], + ], 'crazy-goat/scanmephp'); + + try { + $binaryPath = $packageDir . '/libscanme_qr-linux-glibc-x86_64.so'; + file_put_contents($binaryPath, 'shipped-binary-content'); + + $manager = new ChecksumManager($tempDir, $packageDir); + + $this->assertTrue($manager->hasChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so')); + $this->assertTrue( + $manager->existingBinaryIsValid('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) + ); + } finally { + $this->cleanupFixture($tempDir); + $this->cleanupFixture($packageDir, ['libscanme_qr-linux-glibc-x86_64.so']); + } + } + + public function testRootProjectChecksumsWinOverTheShippedOnes(): void + { + $tempDir = $this->createChecksumFixture([ + '0.5.2' => [ + 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'root-pinned-content'), + ], + ]); + $packageDir = $this->createChecksumFixture([ + '0.5.2' => [ + 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'shipped-content'), + ], + ], 'crazy-goat/scanmephp'); + + try { + $binaryPath = $tempDir . '/libscanme_qr-linux-glibc-x86_64.so'; + file_put_contents($binaryPath, 'root-pinned-content'); + + $manager = new ChecksumManager($tempDir, $packageDir); + + $this->assertSame( + hash('sha256', 'root-pinned-content'), + $manager->getChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so') + ); + $this->assertTrue( + $manager->existingBinaryIsValid('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) + ); + } finally { + $this->cleanupFixture($tempDir, ['libscanme_qr-linux-glibc-x86_64.so']); + $this->cleanupFixture($packageDir); + } + } + + public function testMissingPackageRootIsIgnored(): void + { + $tempDir = $this->createChecksumFixture([ + '0.5.2' => [ + 'libscanme_qr-linux-glibc-x86_64.so' => 'abc123', + ], + ]); + + try { + $manager = new ChecksumManager($tempDir, $tempDir . '/not-installed'); + + $this->assertSame('abc123', $manager->getChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so')); + } finally { + $this->cleanupFixture($tempDir); + } + } + + public function testEmptyChecksumValueIsNotAValidPin(): void + { + $tempDir = $this->createChecksumFixture([ + '0.5.2' => ['libscanme_qr-linux-glibc-x86_64.so' => ''], + ]); + + try { + $manager = new ChecksumManager($tempDir); + + $this->assertFalse($manager->hasChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so')); + } finally { + $this->cleanupFixture($tempDir); + } + } + public function testExistingBinaryIsInvalidWhenFileMissing(): void { $tempDir = $this->createChecksumFixture([ @@ -229,12 +339,12 @@ public function testExistingBinaryIsInvalidWhenFileMissing(): void /** * @param array>|null $checksums */ - private function createChecksumFixture(?array $checksums): string + private function createChecksumFixture(?array $checksums, string $packageName = 'test/project'): string { $tempDir = sys_get_temp_dir() . '/scanme_checksum_test_' . uniqid(); mkdir($tempDir, 0777, true); - $composerJson = ['name' => 'test/project']; + $composerJson = ['name' => $packageName]; if ($checksums !== null) { $composerJson['extra'] = ['scanmephp' => ['checksums' => $checksums]]; } diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index 0370a9d..72d5757 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -85,7 +85,7 @@ public function testPackageInstallKeepsExistingBinaryWhenChecksumMatches(): void $this->assertSame([$binaryName], array_map(basename(...), glob($binaryDir . '/*') ?: [])); } - public function testPackageInstallKeepsExistingBinaryWhenNoChecksumConfigured(): void + public function testPackageInstallReplacesUnverifiedBinaryWhenNoChecksumIsPinned(): void { if (extension_loaded('scanmeqr')) { $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); @@ -97,13 +97,48 @@ public function testPackageInstallKeepsExistingBinaryWhenNoChecksumConfigured(): $binaryPath = $binaryDir . '/' . $binaryName; file_put_contents($binaryPath, 'unverified-binary-content'); - $output = $this->runPackageInstall(['name' => 'test/project']); + $extDownloader = new FailingStubBinaryDownloader($binaryDir); + $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); + + $output = $this->runPackageInstall(['name' => 'test/project'], $plugin); + + $output = implode("\n", $output); + $this->assertStringContainsString('cannot be verified', $output); + $this->assertStringNotContainsString('already exists', $output); + $this->assertFileDoesNotExist($binaryPath, 'an unverifiable binary must not stay where the loader can pick it up'); + $this->assertSame(1, $extDownloader->downloadCalls, 'the verified download path must be attempted exactly once'); + } + + public function testPackageInstallUsesTheChecksumsShippedWithThePackage(): void + { + if (extension_loaded('scanmeqr')) { + $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); + } + + $binaryName = $this->extensionBinaryName(); + $binaryDir = $this->installPath . '/ext-binaries'; + + $extDownloader = new FailingStubBinaryDownloader($binaryDir); + $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); + + // The root project pins nothing; the installed package ships the + // checksums of its own release, which is what makes the verified + // download work without any action from the consumer. + $output = $this->runPackageInstall(['name' => 'test/project'], $plugin, [ + 'name' => 'crazy-goat/scanmephp', + 'extra' => [ + 'scanmephp' => [ + 'checksums' => [ + '0.4.6' => [$binaryName => hash('sha256', 'verified-binary-content')], + ], + ], + ], + ]); $output = implode("\n", $output); - $this->assertStringContainsString('already exists', $output); $this->assertStringNotContainsString('refused', $output); - $this->assertStringNotContainsString('Re-downloading', $output); - $this->assertSame('unverified-binary-content', file_get_contents($binaryPath)); + $this->assertStringContainsString('Extension download failed', $output); + $this->assertSame(1, $extDownloader->downloadCalls, 'the shipped checksum must unlock the verified download path'); } public function testPackageInstallReplacesBinaryWhenExistingChecksumMismatches(): void @@ -273,12 +308,22 @@ public function testPackageInstallWithDirectoryAtTargetPathFailsCleanly(): void } /** + * @param array $composerJson + * @param array|null $packageComposerJson the installed package's own composer.json + * * @return list */ - private function runPackageInstall(array $composerJson, ?Plugin $plugin = null): array + private function runPackageInstall(array $composerJson, ?Plugin $plugin = null, ?array $packageComposerJson = null): array { file_put_contents($this->tempDir . '/composer.json', json_encode($composerJson)); + if ($packageComposerJson !== null) { + if (!is_dir($this->installPath)) { + mkdir($this->installPath, 0777, true); + } + file_put_contents($this->installPath . '/composer.json', json_encode($packageComposerJson)); + } + $output = []; $io = $this->createMock(IOInterface::class); $io->method('write')->willReturnCallback(function (string $message) use (&$output): void { From c019756feac2d2b249ac7be057a3a37385e49c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 17:59:52 +0200 Subject: [PATCH 2/7] fix: state the checksum limitation instead of overclaiming it (#63) Review round 1 was right: the digests of a release only exist after its build, so they can never be in that release's own composer.json. The docs said a verified native install needs no action from the consumer, which is not true and contradicted docs/release-workflow.md in the same change. - CHANGELOG.md and README.md now say that the digests of a version land in composer.json of the *next* release, that a fresh install of the newest one therefore still refuses the download and uses the pure PHP encoder, and how to pin the digests yourself from the release's checksums.txt. - Verify a manual download with --ignore-missing, and mention shasum for macOS, which has no sha256sum. - Rename the plugin test: its fixture exercises the lookup order, it is not a state any real release is in. - findings.md hands the rest of issue point (1) to the maintainer with the three ways out and what each one costs. --- CHANGELOG.md | 15 ++++++++++----- README.md | 28 +++++++++++++++------------- docs/release-workflow.md | 5 +++-- tests/Composer/PluginTest.php | 9 +++++---- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0573d7..fea5ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependabot for Composer and GitHub Actions, and a pull request template. - CI jobs `changes`, `docs` and `ci-ok`; documentation-only changes skip the heavy jobs. - Every release now publishes a `checksums.txt` asset with the SHA-256 of all its binaries, - in `sha256sum` format, so a manual download can be verified with `sha256sum -c checksums.txt`. + in `sha256sum` format, so a manual download can be verified with + `sha256sum -c --ignore-missing checksums.txt`. - `composer.json` pins the SHA-256 of the published binaries of `0.5.0` and `0.5.2` (`extra.scanmephp.checksums`), and the installer reads those pins from the installed - package, so a verified native install no longer needs any action from the consumer. + package as well as from the root project, which takes precedence. `docs/release-workflow.md` documents how to add the checksums of a new release. + **Known limitation:** a release's digests only exist after it has been built, so they land in + `composer.json` of the *next* release. A fresh install of the newest release therefore still + finds no checksum for itself, refuses the download and uses the pure PHP encoder until the + maintainer pins that release's digests; pinning them in your own root `composer.json` (values + are published in `checksums.txt`) makes the verified install work immediately. ### Changed @@ -64,9 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 re-downloaded through the verified (fail-closed) path, which refuses it too instead of accepting an unknown binary (#63). - The Composer plugin reads `extra.scanmephp.checksums` from the installed package's - `composer.json` (the checksums of that release) as well as from the root project, which takes - precedence. A pinned checksum is required for the version and binary in question, so the - native binaries are installed again instead of every install being refused (#63). + `composer.json` (the digests it ships with) as well as from the root project, which takes + precedence, instead of only from the root project (#63). ## [0.5.2] - 2026-08-26 diff --git a/README.md b/README.md index 68cf483..b5dc300 100644 --- a/README.md +++ b/README.md @@ -59,23 +59,16 @@ compiled on the spot with [PIE](https://github.com/php/pie): `pie install crazy- #### Checksum Verification Every downloaded binary is verified before it is written to disk, and a binary that is already in -`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is re-verified on every install. Verification is +`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is verified again on every install. Verification is fail-closed: without a SHA-256 checksum for the requested version and binary the plugin refuses the file and falls back to the pure PHP encoder, which needs no native code. Checksums are read from `extra.scanmephp.checksums`, first from the root project's `composer.json` -and then from the installed package, which ships the checksums of its own release. Every release -also publishes `checksums.txt`, so a manual download can be verified too: - -```bash -gh release download vX.Y.Z -p 'checksums.txt' -sha256sum -c checksums.txt -``` - -If your version has no published checksum yet (for example a version released before this -mechanism existed, or a PHP build the release does not cover), the plugin tells you which binary it -wanted. Pin the digest yourself — take it from the release's `checksums.txt` — and the verified -install works again: +and then from the installed package. A release's digests only exist once that release has been +built, so they are added to `composer.json` of the *following* release: today it pins 0.5.0 and +0.5.2. Until the digests of the version you install are in there, the plugin prints which binary it +wanted and keeps using the pure PHP encoder. Pin the digest yourself — take it from the release's +`checksums.txt` — and the verified install works: ```json { @@ -91,6 +84,15 @@ install works again: } ``` +Every release publishes `checksums.txt` with the SHA-256 of all its binaries, so a manual download +can be verified as well: + +```bash +gh release download vX.Y.Z -p checksums.txt +sha256sum -c --ignore-missing checksums.txt # Linux +shasum -a 256 -c --ignore-missing checksums.txt # macOS +``` + ### PHP Extension Installation (Recommended) The PHP extension provides the best performance. The Composer plugin will attempt to download it automatically. diff --git a/docs/release-workflow.md b/docs/release-workflow.md index a80b563..f2442ee 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -120,8 +120,9 @@ Make sure the next milestone `vX.Y.(Z+1)` (or the next minor) exists. Without this the installer of `vX.Y.Z` has no checksum for its own binaries and refuses the download (fail-closed), so the pure PHP encoder is used. This is a chicken-and-egg problem: - the checksums exist only after the build, so the entry lands in the *next* release; pin them - as soon as possible and keep the previous versions' entries. + the checksums exist only after the build, so the entry lands in the *next* release and a fresh + install of the newest release has no pin for itself until this step is done. Pin them as soon + as possible and keep the previous versions' entries. - Check that install instructions work with the new version (`composer require crazy-goat/scanmephp`, `pie install crazy-goat/qrcode-ext`). diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index 72d5757..ad1a32e 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -109,7 +109,7 @@ public function testPackageInstallReplacesUnverifiedBinaryWhenNoChecksumIsPinned $this->assertSame(1, $extDownloader->downloadCalls, 'the verified download path must be attempted exactly once'); } - public function testPackageInstallUsesTheChecksumsShippedWithThePackage(): void + public function testPackageInstallReadsChecksumsFromTheInstalledPackage(): void { if (extension_loaded('scanmeqr')) { $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); @@ -121,9 +121,10 @@ public function testPackageInstallUsesTheChecksumsShippedWithThePackage(): void $extDownloader = new FailingStubBinaryDownloader($binaryDir); $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); - // The root project pins nothing; the installed package ships the - // checksums of its own release, which is what makes the verified - // download work without any action from the consumer. + // The root project pins nothing and the installed package carries the + // digests, which is the lookup order the installer uses. (No real release + // can pin its *own* digests yet — they only exist after its build — so + // this fixture is the mechanism, not a shipped state.) $output = $this->runPackageInstall(['name' => 'test/project'], $plugin, [ 'name' => 'crazy-goat/scanmephp', 'extra' => [ From 4d19628f07d0bd7f597fbf8a3c667661d65174ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 18:16:19 +0200 Subject: [PATCH 3/7] fix: drop the shipped-checksum mechanism, verify what really happens (#63) Two reviews made the same point: a release's digests only exist after its build, so they can never sit in that release's own composer.json. The pins and the second lookup source therefore changed no real install, while the docs claimed an out-of-the-box native install. Both are gone. What ships instead: - ChecksumManager::existingBinaryIsValid() rejects a binary it has no pinned digest for, so an unverifiable file is re-downloaded through the fail-closed path instead of being accepted (the actual point 2 of the issue). - An empty or non-string value in extra.scanmephp.checksums is not a pin. - The installer says whether a binary failed the digest or had none to check, and reports it when the file cannot be deleted: the loaders probe those paths, so a file left behind would still be loaded. - Every release publishes checksums.txt, the published source a project pins its digests from. README, CHANGELOG and the release docs no longer claim a native install works without a pinned digest; the rest of the issue point is handed to the maintainer in findings.md with the three ways out. Tests: fail-closed verification, the pin reaching the downloader, and a read-only directory making unlink() fail. All three fail on the old code. --- .github/workflows/release-build.yml | 7 +- CHANGELOG.md | 33 +++++---- README.md | 26 +++---- composer.json | 46 +----------- docs/release-workflow.md | 18 +---- src/ChecksumManager.php | 79 +++++++-------------- src/Composer/Plugin.php | 35 ++++++++-- tests/ChecksumManagerTest.php | 104 ++++------------------------ tests/Composer/PluginTest.php | 82 +++++++++++++++------- 9 files changed, 162 insertions(+), 268 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 6eabf45..f8b8ec9 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -227,9 +227,10 @@ jobs: run: ls -la artifacts/ # sha256sum writes the standard " " format, so a consumer can - # verify a download with `sha256sum -c checksums.txt`. The same digests are - # pinned in composer.json (extra.scanmephp.checksums) so the installer can - # verify a download before it writes anything to disk. + # check the binaries they downloaded with + # `sha256sum -c --ignore-missing checksums.txt` (shasum -a 256 on macOS). + # It is the published source for the digests a project pins in its + # composer.json: the installer never trusts a digest fetched from here. - name: Compute SHA-256 checksums run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index fea5ae8..d84a545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,17 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependabot for Composer and GitHub Actions, and a pull request template. - CI jobs `changes`, `docs` and `ci-ok`; documentation-only changes skip the heavy jobs. - Every release now publishes a `checksums.txt` asset with the SHA-256 of all its binaries, - in `sha256sum` format, so a manual download can be verified with + in `sha256sum` format. It is the published source for the digests a project pins in + `extra.scanmephp.checksums`, and it verifies a manual download with `sha256sum -c --ignore-missing checksums.txt`. -- `composer.json` pins the SHA-256 of the published binaries of `0.5.0` and `0.5.2` - (`extra.scanmephp.checksums`), and the installer reads those pins from the installed - package as well as from the root project, which takes precedence. - `docs/release-workflow.md` documents how to add the checksums of a new release. - **Known limitation:** a release's digests only exist after it has been built, so they land in - `composer.json` of the *next* release. A fresh install of the newest release therefore still - finds no checksum for itself, refuses the download and uses the pure PHP encoder until the - maintainer pins that release's digests; pinning them in your own root `composer.json` (values - are published in `checksums.txt`) makes the verified install work immediately. ### Changed @@ -65,13 +57,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- `ChecksumManager::existingBinaryIsValid()` no longer trusts a binary that is already on disk - when no checksum is pinned for it: such a file cannot be verified, so it is rejected and - re-downloaded through the verified (fail-closed) path, which refuses it too instead of - accepting an unknown binary (#63). -- The Composer plugin reads `extra.scanmephp.checksums` from the installed package's - `composer.json` (the digests it ships with) as well as from the root project, which takes - precedence, instead of only from the root project (#63). +- `ChecksumManager::existingBinaryIsValid()` no longer trusts a binary that is already on disk when + no checksum is pinned for it. Such a file cannot be verified, so it is now rejected and + re-downloaded through the verified (fail-closed) path, which refuses it as well instead of + accepting an unknown binary (#63). The installer says whether the file failed the digest or had + no digest to check, and reports it when the file cannot be deleted — the loaders probe these + paths themselves, so a file left behind would still be loaded. +- A checksum that is present but unusable (empty or not a string in a hand-edited + `composer.json`) is no longer treated as a pin (#63). + +Native binaries are still not installed out of the box: the installer only trusts digests pinned +in the project's own `composer.json`, and a release's digests cannot be part of that release. Every +release now publishes them in `checksums.txt`, so pinning one for your platform is a copy-paste +(see the README). Making the install work without that step needs a release-process decision and is +tracked separately. ## [0.5.2] - 2026-08-26 diff --git a/README.md b/README.md index b5dc300..9fd1eef 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,16 @@ compiled on the spot with [PIE](https://github.com/php/pie): `pie install crazy- #### Checksum Verification -Every downloaded binary is verified before it is written to disk, and a binary that is already in -`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is verified again on every install. Verification is -fail-closed: without a SHA-256 checksum for the requested version and binary the plugin refuses the -file and falls back to the pure PHP encoder, which needs no native code. - -Checksums are read from `extra.scanmephp.checksums`, first from the root project's `composer.json` -and then from the installed package. A release's digests only exist once that release has been -built, so they are added to `composer.json` of the *following* release: today it pins 0.5.0 and -0.5.2. Until the digests of the version you install are in there, the plugin prints which binary it -wanted and keeps using the pure PHP encoder. Pin the digest yourself — take it from the release's -`checksums.txt` — and the verified install works: +A downloaded binary is hashed with SHA-256 right after the transfer, compared with the checksum the +installer expects, and deleted again when it does not match. A binary that is already in +`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is verified the same way on every install and +removed when it does not match. Verification is fail-closed: without a checksum for the requested +version and binary, nothing is kept and the plugin prints which binary it wanted. + +Checksums are read from `extra.scanmephp.checksums` in **your** `composer.json` — the installer +never trusts a digest it fetched next to the binary it is checking. Every release publishes the +digests of all its binaries in `checksums.txt`, so pinning the one for your platform takes one +copy-paste: ```json { @@ -84,8 +83,7 @@ wanted and keeps using the pure PHP encoder. Pin the digest yourself — take it } ``` -Every release publishes `checksums.txt` with the SHA-256 of all its binaries, so a manual download -can be verified as well: +Download `checksums.txt` of the release you install and check a manual download with: ```bash gh release download vX.Y.Z -p checksums.txt @@ -93,6 +91,8 @@ sha256sum -c --ignore-missing checksums.txt # Linux shasum -a 256 -c --ignore-missing checksums.txt # macOS ``` +Until you pin a digest, `composer install` installs no native code and the pure PHP encoder is used. + ### PHP Extension Installation (Recommended) The PHP extension provides the best performance. The Composer plugin will attempt to download it automatically. diff --git a/composer.json b/composer.json index 1bfc375..ee96347 100644 --- a/composer.json +++ b/composer.json @@ -35,51 +35,7 @@ } }, "extra": { - "class": "CrazyGoat\\ScanMePHP\\Composer\\Plugin", - "scanmephp": { - "checksums": { - "0.5.0": { - "libscanme_qr-linux-glibc-x86_64.so": "05e5b0b22df2be43ee39bc3fd1732d6c5c8d9b8ac6dd0520e524bc13ccd72030", - "libscanme_qr-linux-musl-x86_64.so": "c6c1c2337cd4f980d34d75729185d9b372426b62ff0d21b40006c024024c7879", - "libscanme_qr-macos-arm64.dylib": "a4727b4ec4273cfdde1e983d0afffc742458ff5ac8015d11dcd5bbf5682157ad", - "libscanme_qr-macos-x86_64.dylib": "0bd9cf89b55d3afea2177e6a55e923d25f0c11ed5fadc622b3d0d1f9507458c0", - "php-ext-linux-glibc-x86_64-php8.1.so": "e65e17bbed60b0fcbf1509534586c234c699d18101e6be3ac5afd6473a233ea2", - "php-ext-linux-glibc-x86_64-php8.2.so": "79392b4145755d2242dd8ef5c3dab8108d4fa7574765b05667325fd595d05609", - "php-ext-linux-glibc-x86_64-php8.3.so": "8a8d21f47909126c06b8148b7aaef703882bef33b9c63b977197b1c58a7feee2", - "php-ext-linux-glibc-x86_64-php8.4.so": "5147d4e74ca49c8f6313eb536330c5c2a2962e789e8709fbe51d00308cf11ea3", - "php-ext-linux-musl-x86_64-php8.1.so": "5aaab1d8cec0249e97df6d03b08bde138ff588a7dac181cae1f26d29a7c2cbb1", - "php-ext-linux-musl-x86_64-php8.2.so": "0bfb13f08955dea97ccd030e4d5ab605565c373900e69bd8c0caf34c7817f56d", - "php-ext-linux-musl-x86_64-php8.3.so": "77e6e6d748c0018379fde7fb90472faff9921c1f5c2b2ea3f9a30e3441616349", - "php-ext-linux-musl-x86_64-php8.4.so": "dd1113721679098ab94cfb8caaff98d4e18b242dbe205427727d459d0596b019", - "php-ext-macos-arm64-php8.1.so": "1705f7c7f29a67ece365e73a1363640f971f8c47ae55bc8c39604a68e93682fe", - "php-ext-macos-arm64-php8.2.so": "241ef01352799906fd3b1088ba17d31f20d3c2ceb105a6c6559db98a1e05d4bd", - "php-ext-macos-arm64-php8.3.so": "c53d9c564d56656acb5fb477d0018c5beb44139f8506cd26f8e3bd575573c1ba", - "php-ext-macos-arm64-php8.4.so": "2cf383f3e71ddddc3761728c0614e9e13b71f7246f51d80bb0da09c90412ad2c", - "php-ext-macos-x86_64-php8.1.so": "89972ca17a3459835faefac457531c24763ea04cb37e4311b6b500c1c7603987", - "php-ext-macos-x86_64-php8.2.so": "fabcd2badab714d621aefb085fdc8cef941d0d30dd51221e8d64054a64cb4351", - "php-ext-macos-x86_64-php8.3.so": "1e84c00a4d684e6551e4d73ccf4c7c307ba3684efec80665560476a5edb7219a", - "php-ext-macos-x86_64-php8.4.so": "3ce80a902be9acccab7bd05cea60f651a93d06692f9304b2e1a8d81d70c0742e" - }, - "0.5.2": { - "libscanme_qr-linux-glibc-x86_64.so": "05e5b0b22df2be43ee39bc3fd1732d6c5c8d9b8ac6dd0520e524bc13ccd72030", - "libscanme_qr-linux-musl-x86_64.so": "c6c1c2337cd4f980d34d75729185d9b372426b62ff0d21b40006c024024c7879", - "libscanme_qr-macos-arm64.dylib": "a4727b4ec4273cfdde1e983d0afffc742458ff5ac8015d11dcd5bbf5682157ad", - "libscanme_qr-macos-x86_64.dylib": "0bd9cf89b55d3afea2177e6a55e923d25f0c11ed5fadc622b3d0d1f9507458c0", - "php-ext-linux-glibc-x86_64-php8.2.so": "d0c354ad01f62e954791b577e56cf1c04814551d5f2f74b8e004dec5aec5942f", - "php-ext-linux-glibc-x86_64-php8.3.so": "7dd53b8b2a12de91f7fddfb6d6cc09d17891834d2d314875682e1309d8313eaf", - "php-ext-linux-glibc-x86_64-php8.4.so": "db39378c1bce1f2392fa67d1f6a5925d675e87634106fa416044a5f93b6b8f31", - "php-ext-linux-musl-x86_64-php8.2.so": "aa476d2922abf3549242d32da09bb59f359ca5801e98779791ae38ca31f62eb3", - "php-ext-linux-musl-x86_64-php8.3.so": "ec8d12726ee50bef4f3f389c6d6d65e5202d1670d9416214ca3b467a1d506c97", - "php-ext-linux-musl-x86_64-php8.4.so": "6fd015b9c9eaf74362b784ac6cd94bd59cd2be6fed459d6480f9ef78cc1e69ea", - "php-ext-macos-arm64-php8.2.so": "02af5fd671401b7cd58bc2120c3f1196d47b0a9fefa25f898eae5e48bfa62b58", - "php-ext-macos-arm64-php8.3.so": "10b8de46497c25e6673661f21964b8e27434c35a73b679ea25a5ffe4534ad783", - "php-ext-macos-arm64-php8.4.so": "4858f86d2db56778ccf6a53d5083ca2cfffd925404b25acfc4b6934356471d48", - "php-ext-macos-x86_64-php8.2.so": "9985f28ffd4965a153cfe289535a14066b94012ad97731ba1a0449df151d0d21", - "php-ext-macos-x86_64-php8.3.so": "aef54cba731dc7b2e6f34dc408b8ccfb28e9a034d0bcdfc80e9b475fab7fb8b9", - "php-ext-macos-x86_64-php8.4.so": "bd3d775e060ceac3c863a3c431827e42af59f74f1cb745fdf1c4c813bf49dc58" - } - } - } + "class": "CrazyGoat\\ScanMePHP\\Composer\\Plugin" }, "scripts": { "test": "phpunit", diff --git a/docs/release-workflow.md b/docs/release-workflow.md index f2442ee..13ea6f0 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -80,6 +80,9 @@ with the assets. It fails when the section is missing or empty. Tags with a `-` example `v0.6.0-rc.1`) become pre-releases. If the release already exists (also as a draft), the workflow uploads the assets to it and publishes it. +`checksums.txt` is the only published record of the digests: the installer trusts only checksums +pinned in the project's own `composer.json`, so this file is where a user copies them from. + Running the workflow by hand (`workflow_dispatch`) from a branch builds every binary and publishes nothing. Do this before tagging when you touched the toolchain, `clib/` or `php-ext/`. @@ -110,20 +113,6 @@ Make sure the next milestone `vX.Y.(Z+1)` (or the next minor) exists. bash bin/build-ext-mirror.sh --push vX.Y.Z ``` -- Pin the checksums of the new binaries in `extra.scanmephp.checksums` of `composer.json` - and merge the result. The digests are published in the release's `checksums.txt`: - - ```bash - gh release download vX.Y.Z -p 'checksums.txt' -D /tmp/vX.Y.Z - grep -E ' (libscanme_qr|php-ext)' /tmp/vX.Y.Z/checksums.txt - ``` - - Without this the installer of `vX.Y.Z` has no checksum for its own binaries and refuses the - download (fail-closed), so the pure PHP encoder is used. This is a chicken-and-egg problem: - the checksums exist only after the build, so the entry lands in the *next* release and a fresh - install of the newest release has no pin for itself until this step is done. Pin them as soon - as possible and keep the previous versions' entries. - - Check that install instructions work with the new version (`composer require crazy-goat/scanmephp`, `pie install crazy-goat/qrcode-ext`). - If something is wrong, do not move the tag. Fix forward with a patch release. @@ -136,6 +125,5 @@ Make sure the next milestone `vX.Y.(Z+1)` (or the next minor) exists. - [ ] `PHP_SCANME_QR_VERSION` bumped - [ ] Annotated tag `vX.Y.Z` pushed - [ ] GitHub Release exists with the CHANGELOG notes, all binaries and `checksums.txt` -- [ ] `composer.json` pins the checksums of the new binaries (see [7. After the release](#7-after-the-release)) - [ ] `qrcode-ext` mirror published - [ ] Milestone closed, next milestone exists diff --git a/src/ChecksumManager.php b/src/ChecksumManager.php index f39b6b7..52d2023 100644 --- a/src/ChecksumManager.php +++ b/src/ChecksumManager.php @@ -6,77 +6,47 @@ class ChecksumManager { - /** - * Checksum maps in lookup order. The first source with an entry for the - * requested version and binary wins: - * - * - the root project may pin checksums itself (extra.scanmephp.checksums), - * - the installed package ships the checksums of its own release. - * - * @var list>> - */ - private array $sources = []; - - /** - * @param string $projectRoot directory of the root project (its composer.json may pin checksums) - * @param string|null $packageRoot directory of the installed package (its composer.json ships the - * checksums of the release); omit it when there is no package - */ - public function __construct(string $projectRoot, ?string $packageRoot = null) - { - $this->addSource($projectRoot); + private ?array $checksums = null; - if ($packageRoot !== null) { - $this->addSource($packageRoot); - } + public function __construct(private readonly string $projectRoot) + { + $this->loadChecksums(); } - private function addSource(string $root): void + private function loadChecksums(): void { - $composerJsonPath = $root . '/composer.json'; - - if (!is_file($composerJsonPath)) { - return; - } + $composerJsonPath = $this->projectRoot . '/composer.json'; - $contents = file_get_contents($composerJsonPath); - - if ($contents === false) { + if (!file_exists($composerJsonPath)) { return; } - $composer = json_decode($contents, true); + $composer = json_decode(file_get_contents($composerJsonPath), true); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($composer)) { + if (json_last_error() !== JSON_ERROR_NONE) { return; } - $checksums = $composer['extra']['scanmephp']['checksums'] ?? null; - - if (!is_array($checksums)) { - return; - } - - $this->sources[] = $checksums; + $this->checksums = $composer['extra']['scanmephp']['checksums'] ?? null; } public function getChecksum(string $version, string $binaryName): ?string { + if ($this->checksums === null) { + return null; + } + // Accept both '0.4.4' and 'v0.4.4' as composer.json version keys. $unprefixed = str_starts_with($version, 'v') ? substr($version, 1) : $version; - foreach ($this->sources as $checksums) { - $checksum = $checksums[$version][$binaryName] - ?? $checksums['v' . $unprefixed][$binaryName] - ?? $checksums[$unprefixed][$binaryName] - ?? null; - - if (is_string($checksum) && $checksum !== '') { - return $checksum; - } - } + $checksum = $this->checksums[$version][$binaryName] + ?? $this->checksums['v' . $unprefixed][$binaryName] + ?? $this->checksums[$unprefixed][$binaryName] + ?? null; - return null; + // An empty or non-string value is not a usable digest: a malformed + // composer.json must not turn into a checksum that never matches. + return is_string($checksum) && $checksum !== '' ? $checksum : null; } public function hasChecksum(string $version, string $binaryName): bool @@ -88,9 +58,10 @@ public function existingBinaryIsValid(string $version, string $binaryName, strin { $checksum = $this->getChecksum($version, $binaryName); - // Fail-closed: without a pinned checksum an on-disk binary cannot be - // verified, so it is not accepted. The caller re-downloads it through - // the verified download path, which refuses without a checksum too. + // Fail-closed: without a pinned checksum the file on disk cannot be + // verified at all, so it is not accepted. The caller re-downloads it + // through the verified download path, which refuses without a checksum + // too instead of trusting an unknown binary. if ($checksum === null) { return false; } diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index 60126e6..f72485e 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -108,9 +108,8 @@ private function installBinaries(\Composer\Package\PackageInterface $package): v return; } - // The installed package ships the checksums of its own release in its composer.json - // (extra.scanmephp.checksums); the root project may pin its own, which wins. - $checksumManager = new ChecksumManager($this->getProjectRoot(), $installPath); + // Checksums are pinned by the root project's composer.json (extra.scanmephp.checksums) + $checksumManager = new ChecksumManager($this->getProjectRoot()); // Try to install PHP extension first (preferred for performance) $extInstalled = $this->installExtensionBinary($installPath, $os, $variant, $arch, $version, $checksumManager); @@ -162,7 +161,9 @@ private function installExtensionBinary(string $installPath, string $os, ?string $binaryName, $checksumManager ) . '. Re-downloading the verified binary.'); - @unlink($targetFile); + if (!$this->removeUnverifiedBinary($targetFile)) { + return false; + } } // Create binary directory @@ -237,7 +238,7 @@ private function installFfiBinary(string $installPath, string $os, ?string $vari $binaryName, $checksumManager ) . '. Re-downloading the verified library.'); - @unlink($targetFile); + $this->removeUnverifiedBinary($targetFile); } // Create binary directory @@ -286,6 +287,30 @@ private function verificationFailure(string $version, string $binaryName, Checks return 'cannot be verified: no SHA-256 checksum is pinned for ' . $binaryName; } + /** + * Delete a binary that failed verification. The loaders probe these paths + * themselves, so a file that stays behind would be used even though the + * installer refused it — say so instead of carrying on. + * + * @return bool whether the path is free (true also when nothing was there) + */ + private function removeUnverifiedBinary(string $path): bool + { + if (!is_file($path)) { + return true; + } + + if (@unlink($path)) { + return true; + } + + $this->io->write('⛔ Could not remove the unverifiable binary at: ' . $path); + $this->io->write(' Delete it by hand; until then it must not be loaded.'); + $this->io->write(' The pure PHP encoder will be used instead.'); + + return false; + } + private function getProjectRoot(): string { return dirname((string) $this->composer->getConfig()->get('vendor-dir')); diff --git a/tests/ChecksumManagerTest.php b/tests/ChecksumManagerTest.php index d4ffeef..2140567 100644 --- a/tests/ChecksumManagerTest.php +++ b/tests/ChecksumManagerTest.php @@ -194,7 +194,7 @@ public function testExistingBinaryIsInvalidWhenNoChecksumConfigured(): void try { // No pinned checksum: the file on disk cannot be verified at all, so - // it is not accepted (fail-closed) and the caller re-downloads it. + // it must not be accepted (fail-closed) — the caller re-downloads it. $binaryPath = $tempDir . '/libscanme_qr-linux-glibc-x86_64.so'; file_put_contents($binaryPath, 'unverified-binary-content'); @@ -208,110 +208,34 @@ public function testExistingBinaryIsInvalidWhenNoChecksumConfigured(): void } } - public function testExistingBinaryIsInvalidWhenOnlyThePackageShipsNoChecksum(): void - { - $tempDir = $this->createChecksumFixture(null); - $packageDir = $this->createChecksumFixture(null, 'crazy-goat/scanmephp'); - - try { - $binaryPath = $tempDir . '/libscanme_qr-linux-glibc-x86_64.so'; - file_put_contents($binaryPath, 'unverified-binary-content'); - - $manager = new ChecksumManager($tempDir, $packageDir); - - $this->assertFalse( - $manager->existingBinaryIsValid('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) - ); - } finally { - $this->cleanupFixture($tempDir, ['libscanme_qr-linux-glibc-x86_64.so']); - $this->cleanupFixture($packageDir); - } - } - - public function testReadsChecksumsShippedWithTheInstalledPackage(): void - { - $tempDir = $this->createChecksumFixture(null); - $packageDir = $this->createChecksumFixture([ - '0.5.2' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'shipped-binary-content'), - ], - ], 'crazy-goat/scanmephp'); - - try { - $binaryPath = $packageDir . '/libscanme_qr-linux-glibc-x86_64.so'; - file_put_contents($binaryPath, 'shipped-binary-content'); - - $manager = new ChecksumManager($tempDir, $packageDir); - - $this->assertTrue($manager->hasChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so')); - $this->assertTrue( - $manager->existingBinaryIsValid('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) - ); - } finally { - $this->cleanupFixture($tempDir); - $this->cleanupFixture($packageDir, ['libscanme_qr-linux-glibc-x86_64.so']); - } - } - - public function testRootProjectChecksumsWinOverTheShippedOnes(): void + public function testEmptyChecksumValueIsNotAPin(): void { $tempDir = $this->createChecksumFixture([ - '0.5.2' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'root-pinned-content'), - ], + 'v0.4.4' => ['libscanme_qr-linux-glibc-x86_64.so' => ''], ]); - $packageDir = $this->createChecksumFixture([ - '0.5.2' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'shipped-content'), - ], - ], 'crazy-goat/scanmephp'); try { - $binaryPath = $tempDir . '/libscanme_qr-linux-glibc-x86_64.so'; - file_put_contents($binaryPath, 'root-pinned-content'); - - $manager = new ChecksumManager($tempDir, $packageDir); - - $this->assertSame( - hash('sha256', 'root-pinned-content'), - $manager->getChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so') - ); - $this->assertTrue( - $manager->existingBinaryIsValid('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so', $binaryPath) - ); - } finally { - $this->cleanupFixture($tempDir, ['libscanme_qr-linux-glibc-x86_64.so']); - $this->cleanupFixture($packageDir); - } - } - - public function testMissingPackageRootIsIgnored(): void - { - $tempDir = $this->createChecksumFixture([ - '0.5.2' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => 'abc123', - ], - ]); - - try { - $manager = new ChecksumManager($tempDir, $tempDir . '/not-installed'); + $manager = new ChecksumManager($tempDir); - $this->assertSame('abc123', $manager->getChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so')); + $this->assertFalse($manager->hasChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so')); + $this->assertNull($manager->getChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so')); } finally { $this->cleanupFixture($tempDir); } } - public function testEmptyChecksumValueIsNotAValidPin(): void + public function testNonStringChecksumValueIsNotAPin(): void { + // A hand-edited composer.json can hold anything; a number must not come + // back as a checksum (it would raise a TypeError under strict_types). $tempDir = $this->createChecksumFixture([ - '0.5.2' => ['libscanme_qr-linux-glibc-x86_64.so' => ''], + 'v0.4.4' => ['libscanme_qr-linux-glibc-x86_64.so' => 12345], ]); try { $manager = new ChecksumManager($tempDir); - $this->assertFalse($manager->hasChecksum('0.5.2', 'libscanme_qr-linux-glibc-x86_64.so')); + $this->assertFalse($manager->hasChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so')); } finally { $this->cleanupFixture($tempDir); } @@ -337,14 +261,14 @@ public function testExistingBinaryIsInvalidWhenFileMissing(): void } /** - * @param array>|null $checksums + * @param array $checksums */ - private function createChecksumFixture(?array $checksums, string $packageName = 'test/project'): string + private function createChecksumFixture(?array $checksums): string { $tempDir = sys_get_temp_dir() . '/scanme_checksum_test_' . uniqid(); mkdir($tempDir, 0777, true); - $composerJson = ['name' => $packageName]; + $composerJson = ['name' => 'test/project']; if ($checksums !== null) { $composerJson['extra'] = ['scanmephp' => ['checksums' => $checksums]]; } diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index ad1a32e..e073777 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -106,40 +106,78 @@ public function testPackageInstallReplacesUnverifiedBinaryWhenNoChecksumIsPinned $this->assertStringContainsString('cannot be verified', $output); $this->assertStringNotContainsString('already exists', $output); $this->assertFileDoesNotExist($binaryPath, 'an unverifiable binary must not stay where the loader can pick it up'); - $this->assertSame(1, $extDownloader->downloadCalls, 'the verified download path must be attempted exactly once'); + $this->assertSame(1, $extDownloader->downloadCalls, 'the verified download path must be attempted once'); } - public function testPackageInstallReadsChecksumsFromTheInstalledPackage(): void + public function testPinnedChecksumReachesTheVerifiedDownloadPath(): void { if (extension_loaded('scanmeqr')) { $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); } $binaryName = $this->extensionBinaryName(); - $binaryDir = $this->installPath . '/ext-binaries'; + $pinned = hash('sha256', 'verified-binary-content'); - $extDownloader = new FailingStubBinaryDownloader($binaryDir); - $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); + // The downloader resolves the digest it verifies against from this + // manager, so a manager that lost the root project's pin would make the + // fail-closed download refuse a perfectly good binary. + $seen = null; + $plugin = new StubDownloaderPlugin(function (string $path, string $version, ChecksumManager $manager) use (&$seen): BinaryDownloader { + $seen ??= $manager; + + return new FailingStubBinaryDownloader($path); + }); - // The root project pins nothing and the installed package carries the - // digests, which is the lookup order the installer uses. (No real release - // can pin its *own* digests yet — they only exist after its build — so - // this fixture is the mechanism, not a shipped state.) - $output = $this->runPackageInstall(['name' => 'test/project'], $plugin, [ - 'name' => 'crazy-goat/scanmephp', + $output = $this->runPackageInstall([ + 'name' => 'test/project', 'extra' => [ 'scanmephp' => [ 'checksums' => [ - '0.4.6' => [$binaryName => hash('sha256', 'verified-binary-content')], + '0.4.6' => [$binaryName => $pinned], ], ], ], - ]); + ], $plugin); - $output = implode("\n", $output); - $this->assertStringNotContainsString('refused', $output); - $this->assertStringContainsString('Extension download failed', $output); - $this->assertSame(1, $extDownloader->downloadCalls, 'the shipped checksum must unlock the verified download path'); + $this->assertNotNull($seen, 'the plugin must hand its ChecksumManager to the downloader'); + $this->assertSame($pinned, $seen->getChecksum('0.4.6', $binaryName)); + $this->assertTrue($seen->hasChecksum('0.4.6', $binaryName)); + $this->assertStringContainsString('Extension download failed', implode("\n", $output)); + } + + public function testUnremovableBinaryIsReportedInsteadOfDownloaded(): void + { + if (extension_loaded('scanmeqr')) { + $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); + } + + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('root ignores directory permissions, so unlink() cannot be made to fail'); + } + + $binaryName = $this->extensionBinaryName(); + $binaryDir = $this->installPath . '/ext-binaries'; + mkdir($binaryDir, 0777, true); + $binaryPath = $binaryDir . '/' . $binaryName; + file_put_contents($binaryPath, 'unverified-binary-content'); + // Read-only directory: unlink() of the entry fails, which must not be + // swallowed — the loaders would still find the file there. + chmod($binaryDir, 0500); + + try { + $extDownloader = new FailingStubBinaryDownloader($binaryDir); + $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); + + $output = implode("\n", $this->runPackageInstall(['name' => 'test/project'], $plugin)); + + $this->assertStringContainsString('Could not remove the unverifiable binary', $output); + $this->assertStringNotContainsString('Downloading extension binary', $output); + $this->assertSame(0, $extDownloader->downloadCalls, 'a binary that is still there must not be replaced silently'); + $this->assertSame('unverified-binary-content', file_get_contents($binaryPath)); + } finally { + chmod($binaryDir, 0700); + unlink($binaryPath); + } } public function testPackageInstallReplacesBinaryWhenExistingChecksumMismatches(): void @@ -310,21 +348,13 @@ public function testPackageInstallWithDirectoryAtTargetPathFailsCleanly(): void /** * @param array $composerJson - * @param array|null $packageComposerJson the installed package's own composer.json * * @return list */ - private function runPackageInstall(array $composerJson, ?Plugin $plugin = null, ?array $packageComposerJson = null): array + private function runPackageInstall(array $composerJson, ?Plugin $plugin = null): array { file_put_contents($this->tempDir . '/composer.json', json_encode($composerJson)); - if ($packageComposerJson !== null) { - if (!is_dir($this->installPath)) { - mkdir($this->installPath, 0777, true); - } - file_put_contents($this->installPath . '/composer.json', json_encode($packageComposerJson)); - } - $output = []; $io = $this->createMock(IOInterface::class); $io->method('write')->willReturnCallback(function (string $message) use (&$output): void { From 6229aa24e3ed50e74494347a36e7adc87a51547f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 18:31:25 +0200 Subject: [PATCH 4/7] fix: stop both installer branches on a binary they cannot delete (#63) Round 3 findings. - The FFI branch ignored removeUnverifiedBinary()'s result while the extension branch honoured it, so a file that could not be deleted was truncated by the next download. Both branches stop now, and the read-only directory test covers each of them. - "Re-downloading the verified binary." is printed only when a download follows, and the "could not remove" message no longer promises the pure PHP encoder: installBinaries() still tries the FFI path after the extension path gives up. - Replaced testPinnedChecksumReachesTheVerifiedDownloadPath. It pinned the project root, which main already wires, so it passed on main and the commit message counted it as regression coverage it was not. The new testPackageInstallSurvivesANonStringPin does fail on main: there an int pin comes back out of getChecksum(): ?string as a TypeError, which is an Error and escapes catch(\Exception), aborting the install. Verified in a scratch copy with src/ restored from origin/main: all seven new and changed tests fail there. - README: verification runs when ScanMePHP is installed or updated, not on every composer install, and the pin example says to copy the digest column only - a whole sha256sum line is a pin that never matches. - findings.md: InstallScript::run() is dead code, not a live hole. --- CHANGELOG.md | 10 +++-- README.md | 26 ++++++----- src/Composer/Plugin.php | 21 +++++---- tests/Composer/PluginTest.php | 81 ++++++++++++++++++++++++++--------- 4 files changed, 95 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84a545..0b013cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Every release now publishes a `checksums.txt` asset with the SHA-256 of all its binaries, in `sha256sum` format. It is the published source for the digests a project pins in `extra.scanmephp.checksums`, and it verifies a manual download with - `sha256sum -c --ignore-missing checksums.txt`. + `sha256sum -c --ignore-missing checksums.txt` (`shasum -a 256 -c` on macOS). ### Changed @@ -61,10 +61,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 no checksum is pinned for it. Such a file cannot be verified, so it is now rejected and re-downloaded through the verified (fail-closed) path, which refuses it as well instead of accepting an unknown binary (#63). The installer says whether the file failed the digest or had - no digest to check, and reports it when the file cannot be deleted — the loaders probe these - paths themselves, so a file left behind would still be loaded. + no digest to check, and reports it instead of downloading when the file cannot be deleted — the + loaders probe these paths, so a leftover file would still be loaded. - A checksum that is present but unusable (empty or not a string in a hand-edited - `composer.json`) is no longer treated as a pin (#63). + `composer.json`) is no longer treated as a pin (#63). A non-string value used to be returned from + `getChecksum(): ?string`, raising a `TypeError` — an `Error`, so it escaped the plugin's + `catch (\Exception)` and aborted the whole `composer install`. Native binaries are still not installed out of the box: the installer only trusts digests pinned in the project's own `composer.json`, and a release's digests cannot be part of that release. Every diff --git a/README.md b/README.md index 9fd1eef..c1c212c 100644 --- a/README.md +++ b/README.md @@ -60,22 +60,28 @@ compiled on the spot with [PIE](https://github.com/php/pie): `pie install crazy- A downloaded binary is hashed with SHA-256 right after the transfer, compared with the checksum the installer expects, and deleted again when it does not match. A binary that is already in -`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is verified the same way on every install and -removed when it does not match. Verification is fail-closed: without a checksum for the requested -version and binary, nothing is kept and the plugin prints which binary it wanted. +`vendor/crazy-goat/scanmephp/{ext,ffi}-binaries/` is checked the same way whenever the plugin runs, +that is when ScanMePHP is installed or updated — not on a plain `composer install` that leaves the +installed version alone. Verification is fail-closed: without a checksum for the requested version +and binary, nothing is kept and the plugin prints which binary it wanted. Checksums are read from `extra.scanmephp.checksums` in **your** `composer.json` — the installer never trusts a digest it fetched next to the binary it is checking. Every release publishes the -digests of all its binaries in `checksums.txt`, so pinning the one for your platform takes one +digests of all its binaries in `checksums.txt`, so pinning the one for your platform is a copy-paste: +```bash +gh release download vX.Y.Z -p checksums.txt +grep libscanme_qr-linux-glibc checksums.txt # " " +``` + ```json { "extra": { "scanmephp": { "checksums": { "0.5.2": { - "libscanme_qr-linux-glibc-x86_64.so": "" + "libscanme_qr-linux-glibc-x86_64.so": "" } } } @@ -83,13 +89,11 @@ copy-paste: } ``` -Download `checksums.txt` of the release you install and check a manual download with: +Copy the 64-character digest only. A whole `sha256sum` line pasted as the value is a pin that never +matches, so every install deletes the binary and then refuses to download it again. -```bash -gh release download vX.Y.Z -p checksums.txt -sha256sum -c --ignore-missing checksums.txt # Linux -shasum -a 256 -c --ignore-missing checksums.txt # macOS -``` +The same file verifies a manual download; check just what you have with +`sha256sum -c --ignore-missing checksums.txt` (Linux) or `shasum -a 256 -c checksums.txt` (macOS). Until you pin a digest, `composer install` installs no native code and the pure PHP encoder is used. diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index f72485e..e47a9a2 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -154,16 +154,17 @@ private function installExtensionBinary(string $installPath, string $os, ?string return true; } - // The on-disk binary cannot be verified: remove it and re-download + // The on-disk binary cannot be verified: remove it, then re-download // through the verified (fail-closed) download path. $this->io->write('⚠️ Existing extension binary ' . $this->verificationFailure( $version, $binaryName, $checksumManager - ) . '. Re-downloading the verified binary.'); + ) . '.'); if (!$this->removeUnverifiedBinary($targetFile)) { return false; } + $this->io->write(' Re-downloading the verified binary.'); } // Create binary directory @@ -231,14 +232,17 @@ private function installFfiBinary(string $installPath, string $os, ?string $vari return; } - // The on-disk binary cannot be verified: remove it and re-download + // The on-disk binary cannot be verified: remove it, then re-download // through the verified (fail-closed) download path. $this->io->write('⚠️ Existing FFI library ' . $this->verificationFailure( $version, $binaryName, $checksumManager - ) . '. Re-downloading the verified library.'); - $this->removeUnverifiedBinary($targetFile); + ) . '.'); + if (!$this->removeUnverifiedBinary($targetFile)) { + return; + } + $this->io->write(' Re-downloading the verified library.'); } // Create binary directory @@ -290,7 +294,8 @@ private function verificationFailure(string $version, string $binaryName, Checks /** * Delete a binary that failed verification. The loaders probe these paths * themselves, so a file that stays behind would be used even though the - * installer refused it — say so instead of carrying on. + * installer refused it — say so instead of carrying on. The caller must not + * continue with the same path: the download opens it for writing. * * @return bool whether the path is free (true also when nothing was there) */ @@ -305,8 +310,8 @@ private function removeUnverifiedBinary(string $path): bool } $this->io->write('⛔ Could not remove the unverifiable binary at: ' . $path); - $this->io->write(' Delete it by hand; until then it must not be loaded.'); - $this->io->write(' The pure PHP encoder will be used instead.'); + $this->io->write(' It is still on disk and can be loaded, so delete it by hand.'); + $this->io->write(' Nothing was downloaded in its place.'); return false; } diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index e073777..8b0b069 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -109,40 +109,38 @@ public function testPackageInstallReplacesUnverifiedBinaryWhenNoChecksumIsPinned $this->assertSame(1, $extDownloader->downloadCalls, 'the verified download path must be attempted once'); } - public function testPinnedChecksumReachesTheVerifiedDownloadPath(): void + public function testPackageInstallSurvivesANonStringPin(): void { if (extension_loaded('scanmeqr')) { $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); } $binaryName = $this->extensionBinaryName(); - $pinned = hash('sha256', 'verified-binary-content'); - - // The downloader resolves the digest it verifies against from this - // manager, so a manager that lost the root project's pin would make the - // fail-closed download refuse a perfectly good binary. - $seen = null; - $plugin = new StubDownloaderPlugin(function (string $path, string $version, ChecksumManager $manager) use (&$seen): BinaryDownloader { - $seen ??= $manager; + $binaryDir = $this->installPath . '/ext-binaries'; + mkdir($binaryDir, 0777, true); + $binaryPath = $binaryDir . '/' . $binaryName; + file_put_contents($binaryPath, 'unverified-binary-content'); - return new FailingStubBinaryDownloader($path); - }); + $extDownloader = new FailingStubBinaryDownloader($binaryDir); + $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); + // A hand-edited composer.json can hold a number where a digest belongs. + // On main that value comes back out of getChecksum(): ?string as an int, + // which is a TypeError — and an Error, so it escapes the installer's + // catch(\Exception) and kills the whole composer install. $output = $this->runPackageInstall([ 'name' => 'test/project', 'extra' => [ 'scanmephp' => [ - 'checksums' => [ - '0.4.6' => [$binaryName => $pinned], - ], + 'checksums' => ['0.4.6' => [$binaryName => 12345]], ], ], ], $plugin); - $this->assertNotNull($seen, 'the plugin must hand its ChecksumManager to the downloader'); - $this->assertSame($pinned, $seen->getChecksum('0.4.6', $binaryName)); - $this->assertTrue($seen->hasChecksum('0.4.6', $binaryName)); - $this->assertStringContainsString('Extension download failed', implode("\n", $output)); + $output = implode("\n", $output); + $this->assertStringContainsString('cannot be verified', $output); + $this->assertFileDoesNotExist($binaryPath); + $this->assertSame(1, $extDownloader->downloadCalls); } public function testUnremovableBinaryIsReportedInsteadOfDownloaded(): void @@ -171,6 +169,7 @@ public function testUnremovableBinaryIsReportedInsteadOfDownloaded(): void $output = implode("\n", $this->runPackageInstall(['name' => 'test/project'], $plugin)); $this->assertStringContainsString('Could not remove the unverifiable binary', $output); + $this->assertStringNotContainsString('Re-downloading the verified binary', $output); $this->assertStringNotContainsString('Downloading extension binary', $output); $this->assertSame(0, $extDownloader->downloadCalls, 'a binary that is still there must not be replaced silently'); $this->assertSame('unverified-binary-content', file_get_contents($binaryPath)); @@ -180,6 +179,46 @@ public function testUnremovableBinaryIsReportedInsteadOfDownloaded(): void } } + public function testUnremovableFfiLibraryIsReportedInsteadOfDownloaded(): void + { + if (extension_loaded('scanmeqr') || !extension_loaded('ffi')) { + $this->markTestSkipped('requires the FFI extension (FAQ-003) and no preloaded scanmeqr extension'); + } + + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('root ignores directory permissions, so unlink() cannot be made to fail'); + } + + $os = PlatformDetector::getOperatingSystem(); + $arch = PlatformDetector::getArchitecture(); + $variant = $os === 'linux' ? PlatformDetector::getLinuxVariant() : null; + $binaryName = PlatformDetector::getBinaryName($os, $variant, $arch); + $binaryDir = $this->installPath . '/ffi-binaries'; + mkdir($binaryDir, 0777, true); + $binaryPath = $binaryDir . '/' . $binaryName; + file_put_contents($binaryPath, 'unverified-ffi-binary-content'); + chmod($binaryDir, 0500); + + try { + $extDownloader = new FailingStubBinaryDownloader($this->installPath . '/ext-binaries'); + $ffiDownloader = new FailingStubBinaryDownloader($binaryDir); + $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader, $ffiDownloader)); + + $output = implode("\n", $this->runPackageInstall(['name' => 'test/project'], $plugin)); + + // Same guarantee as the extension branch: no download into a path + // that could not be cleared, and the file is still there. + $this->assertStringContainsString('Could not remove the unverifiable binary', $output); + $this->assertStringNotContainsString('Re-downloading the verified library', $output); + $this->assertStringNotContainsString('Downloading FFI library', $output); + $this->assertSame(0, $ffiDownloader->downloadCalls); + $this->assertSame('unverified-ffi-binary-content', file_get_contents($binaryPath)); + } finally { + chmod($binaryDir, 0700); + unlink($binaryPath); + } + } + public function testPackageInstallReplacesBinaryWhenExistingChecksumMismatches(): void { if (extension_loaded('scanmeqr')) { @@ -210,7 +249,8 @@ public function testPackageInstallReplacesBinaryWhenExistingChecksumMismatches() ], $plugin); $output = implode("\n", $output); - $this->assertStringContainsString('failed SHA-256 verification. Re-downloading', $output); + $this->assertStringContainsString('failed SHA-256 verification.', $output); + $this->assertStringContainsString('Re-downloading the verified', $output); $this->assertStringNotContainsString('already exists', $output); $this->assertFileDoesNotExist($binaryPath, 'the mismatched binary must be unlinked before the re-download'); $this->assertStringContainsString('Extension download failed', $output); @@ -250,7 +290,8 @@ public function testPackageInstallReplacesFfiBinaryWhenExistingChecksumMismatche ], $plugin); $output = implode("\n", $output); - $this->assertStringContainsString('failed SHA-256 verification. Re-downloading', $output); + $this->assertStringContainsString('failed SHA-256 verification.', $output); + $this->assertStringContainsString('Re-downloading the verified', $output); $this->assertFileDoesNotExist($binaryPath, 'the mismatched FFI library must be unlinked before the re-download'); $this->assertStringContainsString('FFI library download failed', $output); $this->assertSame(1, $ffiDownloader->downloadCalls, 'the FFI verified download path must be invoked exactly once'); From c472d932a44d8dab8f3bfd9daafa6cd072a244d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 18:45:43 +0200 Subject: [PATCH 5/7] docs: the checksums.txt pinning example must not name a release (#63) Round 4 finding. No published release has checksums.txt - the release job that writes it is still unreleased - so `gh release download vX.Y.Z` with the concrete "0.5.2" from the example 404s for every version that exists today. - The example pins "X.Y.Z", and both the README and the CHANGELOG say once that the file ships with the first release containing this change and that earlier releases have none. - CHANGELOG also states the behaviour change a user will hit first: `composer update` to a version with no pinned digest now deletes a previously verified binary and then refuses to replace it. --- CHANGELOG.md | 10 ++++++++-- README.md | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b013cd..21f9953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Every release now publishes a `checksums.txt` asset with the SHA-256 of all its binaries, in `sha256sum` format. It is the published source for the digests a project pins in `extra.scanmephp.checksums`, and it verifies a manual download with - `sha256sum -c --ignore-missing checksums.txt` (`shasum -a 256 -c` on macOS). + `sha256sum -c --ignore-missing checksums.txt` (`shasum -a 256 -c` on macOS). The file ships with + the first release that contains this change; earlier releases have no `checksums.txt`. ### Changed @@ -72,7 +73,12 @@ Native binaries are still not installed out of the box: the installer only trust in the project's own `composer.json`, and a release's digests cannot be part of that release. Every release now publishes them in `checksums.txt`, so pinning one for your platform is a copy-paste (see the README). Making the install work without that step needs a release-process decision and is -tracked separately. +tracked in #260. + +**Behaviour change to be aware of:** `composer update` to a version whose binaries have no pinned +digest now *deletes* a previously verified binary and then refuses to replace it, so native +acceleration disappears until the digest is pinned. That is the fail-closed behaviour #63 asks for, +but it is visible to anyone who updates without pinning. ## [0.5.2] - 2026-08-26 diff --git a/README.md b/README.md index c1c212c..427e9f8 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ and binary, nothing is kept and the plugin prints which binary it wanted. Checksums are read from `extra.scanmephp.checksums` in **your** `composer.json` — the installer never trusts a digest it fetched next to the binary it is checking. Every release publishes the digests of all its binaries in `checksums.txt`, so pinning the one for your platform is a -copy-paste: +copy-paste. That file exists from the first release that ships this feature; earlier releases have +none, so there is nothing to copy from them and the pure PHP encoder is used until the release you +install has one. ```bash gh release download vX.Y.Z -p checksums.txt @@ -80,7 +82,7 @@ grep libscanme_qr-linux-glibc checksums.txt # " " "extra": { "scanmephp": { "checksums": { - "0.5.2": { + "X.Y.Z": { "libscanme_qr-linux-glibc-x86_64.so": "" } } From ed92f1369fa27e90f2112b226bcbbb28528e1c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 19:12:29 +0200 Subject: [PATCH 6/7] fix: reject a malformed checksum section and a malformed pin (#63) Round 5 findings, fixed in ChecksumManager rather than in the prose. - A scalar extra.scanmephp.checksums (a string, int, bool or float instead of a version map) was assigned to the ?array property and raised a TypeError. That is an Error, so it escaped the plugin's catch (\Exception) and aborted the whole composer install. A non-array section now reads as "nothing is pinned", which is the fail-closed path it should always have taken. - Only a 64-character lowercase hex digest is a pin. A whole `sha256sum` line copied out of checksums.txt, an empty value or an uppercase digest can never match: each of them downloaded the multi-megabyte binary and then threw checksumMismatch on every install. They are refused before the transfer now, which is what the README section already claimed. - The three legacy fixtures that used 'abc123def456' as a stand-in digest now use real hash('sha256', ...) values, since the shape is validated. Tests at both levels - ChecksumManagerTest and PluginTest - and all eleven new or changed tests fail on origin/main with src/ restored. --- CHANGELOG.md | 13 ++++--- src/ChecksumManager.php | 15 +++++--- tests/ChecksumManagerTest.php | 68 ++++++++++++++++++++++++++++++++--- tests/Composer/PluginTest.php | 58 ++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f9953..8243651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,10 +64,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 accepting an unknown binary (#63). The installer says whether the file failed the digest or had no digest to check, and reports it instead of downloading when the file cannot be deleted — the loaders probe these paths, so a leftover file would still be loaded. -- A checksum that is present but unusable (empty or not a string in a hand-edited - `composer.json`) is no longer treated as a pin (#63). A non-string value used to be returned from - `getChecksum(): ?string`, raising a `TypeError` — an `Error`, so it escaped the plugin's - `catch (\Exception)` and aborted the whole `composer install`. +- A malformed `extra.scanmephp.checksums` can no longer abort a `composer install` (#63). A + non-string value inside the version map was returned from `getChecksum(): ?string` as a + `TypeError`, and a scalar section was assigned to the `?array` property as another one — both + `\Error`s, which escape the plugin's `catch (\Exception)`. Either way the install now reads as + "nothing is pinned", which is the fail-closed path it should have taken. +- Only a 64-character lowercase hex digest counts as a pinned checksum (#63). A pin of any other + shape — an empty value, an uppercase digest, or a whole `sha256sum` line copied out of + `checksums.txt` — can never match, so it is refused up front instead of downloading the binary + and then throwing `checksumMismatch` on every install. Native binaries are still not installed out of the box: the installer only trusts digests pinned in the project's own `composer.json`, and a release's digests cannot be part of that release. Every diff --git a/src/ChecksumManager.php b/src/ChecksumManager.php index 52d2023..43bdf3f 100644 --- a/src/ChecksumManager.php +++ b/src/ChecksumManager.php @@ -27,7 +27,12 @@ private function loadChecksums(): void return; } - $this->checksums = $composer['extra']['scanmephp']['checksums'] ?? null; + // A malformed section is no pin at all, which is the fail-closed path. + // Assigning a scalar to the ?array property would raise a TypeError, and + // a TypeError is an \Error: it escapes the plugin's catch (\Exception) + // and aborts the whole composer install. + $checksums = $composer['extra']['scanmephp']['checksums'] ?? null; + $this->checksums = is_array($checksums) ? $checksums : null; } public function getChecksum(string $version, string $binaryName): ?string @@ -44,9 +49,11 @@ public function getChecksum(string $version, string $binaryName): ?string ?? $this->checksums[$unprefixed][$binaryName] ?? null; - // An empty or non-string value is not a usable digest: a malformed - // composer.json must not turn into a checksum that never matches. - return is_string($checksum) && $checksum !== '' ? $checksum : null; + // Only a SHA-256 digest is usable. Anything else — an empty value, a + // number, a whole `sha256sum` line pasted by mistake — is not a pin: it + // can never match, so accepting it would fetch the binary and then throw + // checksumMismatch on every install instead of refusing up front. + return is_string($checksum) && preg_match('/^[0-9a-f]{64}$/', $checksum) === 1 ? $checksum : null; } public function hasChecksum(string $version, string $binaryName): bool diff --git a/tests/ChecksumManagerTest.php b/tests/ChecksumManagerTest.php index 2140567..9af803a 100644 --- a/tests/ChecksumManagerTest.php +++ b/tests/ChecksumManagerTest.php @@ -22,7 +22,7 @@ public function testLoadsChecksumsFromComposerExtra(): void 'scanmephp' => [ 'checksums' => [ 'v0.4.4' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => 'abc123def456', + 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'abc123def456'), ], ], ], @@ -37,7 +37,7 @@ public function testLoadsChecksumsFromComposerExtra(): void $manager = new ChecksumManager($tempDir); $checksum = $manager->getChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so'); - $this->assertEquals('abc123def456', $checksum); + $this->assertEquals(hash('sha256', 'abc123def456'), $checksum); } finally { if (is_dir($tempDir)) { unlink($tempDir . '/composer.json'); @@ -83,7 +83,7 @@ public function testGetChecksumIgnoresVPrefixMismatch(): void 'scanmephp' => [ 'checksums' => [ 'v0.4.4' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => 'abc123def456', + 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'abc123def456'), ], ], ], @@ -98,7 +98,7 @@ public function testGetChecksumIgnoresVPrefixMismatch(): void $manager = new ChecksumManager($tempDir); $checksum = $manager->getChecksum('0.4.4', 'libscanme_qr-linux-glibc-x86_64.so'); - $this->assertEquals('abc123def456', $checksum); + $this->assertEquals(hash('sha256', 'abc123def456'), $checksum); } finally { if (is_dir($tempDir)) { unlink($tempDir . '/composer.json'); @@ -120,7 +120,7 @@ public function testHasChecksumResolvesUnprefixedKeys(): void 'scanmephp' => [ 'checksums' => [ '0.4.4' => [ - 'libscanme_qr-linux-glibc-x86_64.so' => 'abc123def456', + 'libscanme_qr-linux-glibc-x86_64.so' => hash('sha256', 'abc123def456'), ], ], ], @@ -241,6 +241,64 @@ public function testNonStringChecksumValueIsNotAPin(): void } } + public function testNonStringChecksumSectionIsNotAPin(): void + { + // A scalar where the version map belongs used to be assigned straight to + // the ?array property: a TypeError, i.e. an \Error, which escapes the + // plugin's catch (\Exception) and aborts the whole composer install. + // Written by hand, because the fixture helper takes a version map. + $tempDir = sys_get_temp_dir() . '/scanme_checksum_test_' . uniqid(); + mkdir($tempDir, 0777, true); + file_put_contents($tempDir . '/composer.json', json_encode([ + 'name' => 'test/project', + 'extra' => ['scanmephp' => ['checksums' => 'oops']], + ])); + + try { + $manager = new ChecksumManager($tempDir); + + $this->assertFalse($manager->hasChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so')); + $this->assertNull($manager->getChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so')); + } finally { + $this->cleanupFixture($tempDir); + } + } + + public function testWholeSha256SumLineIsNotAPin(): void + { + // The README tells users to copy the digest out of checksums.txt; a whole + // `sha256sum` line pasted by mistake can never match, so it must be + // refused before the multi-megabyte download, not after it. + $binaryName = 'libscanme_qr-linux-glibc-x86_64.so'; + $digest = hash('sha256', 'binary-content'); + $tempDir = $this->createChecksumFixture([ + 'v0.4.4' => [$binaryName => $digest . ' ' . $binaryName], + ]); + + try { + $manager = new ChecksumManager($tempDir); + + $this->assertFalse($manager->hasChecksum('v0.4.4', $binaryName)); + $this->assertNull($manager->getChecksum('v0.4.4', $binaryName)); + } finally { + $this->cleanupFixture($tempDir); + } + } + + public function testUppercaseAndShortDigestsAreNotPins(): void + { + $binaryName = 'libscanme_qr-linux-glibc-x86_64.so'; + $tempDir = $this->createChecksumFixture([ + 'v0.4.4' => [$binaryName => strtoupper(hash('sha256', 'binary-content'))], + ]); + + try { + $this->assertFalse((new ChecksumManager($tempDir))->hasChecksum('v0.4.4', $binaryName)); + } finally { + $this->cleanupFixture($tempDir); + } + } + public function testExistingBinaryIsInvalidWhenFileMissing(): void { $tempDir = $this->createChecksumFixture([ diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index 8b0b069..ead732b 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -143,6 +143,64 @@ public function testPackageInstallSurvivesANonStringPin(): void $this->assertSame(1, $extDownloader->downloadCalls); } + public function testPackageInstallSurvivesANonStringChecksumSection(): void + { + if (extension_loaded('scanmeqr')) { + $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); + } + + $binaryName = $this->extensionBinaryName(); + $binaryDir = $this->installPath . '/ext-binaries'; + mkdir($binaryDir, 0777, true); + $binaryPath = $binaryDir . '/' . $binaryName; + file_put_contents($binaryPath, 'unverified-binary-content'); + + $extDownloader = new FailingStubBinaryDownloader($binaryDir); + $plugin = new StubDownloaderPlugin($this->downloadFactory($extDownloader)); + + // A scalar where the version map belongs. On main this reached the + // ?array property assignment and raised a TypeError, which is an Error + // and therefore escapes the plugin's catch (\Exception). + $output = $this->runPackageInstall([ + 'name' => 'test/project', + 'extra' => ['scanmephp' => ['checksums' => 'oops']], + ], $plugin); + + $output = implode("\n", $output); + $this->assertStringContainsString('cannot be verified', $output); + $this->assertFileDoesNotExist($binaryPath); + $this->assertSame(1, $extDownloader->downloadCalls); + } + + public function testPackageInstallRefusesAWholeSha256SumLineBeforeDownloading(): void + { + if (extension_loaded('scanmeqr')) { + $this->markTestSkipped('scanmeqr extension loaded; the plugin skips binary installation entirely'); + } + + $binaryName = $this->extensionBinaryName(); + $digest = hash('sha256', 'verified-binary-content'); + + // The mistake the README warns about: the whole checksums.txt line as + // the value. No stub downloader here on purpose — the real one refuses + // a download without a usable digest before it opens a connection, so + // "refused" in the output is the pre-download refusal and nothing was + // fetched. + $output = $this->runPackageInstall([ + 'name' => 'test/project', + 'extra' => [ + 'scanmephp' => [ + 'checksums' => ['0.4.6' => [$binaryName => $digest . ' ' . $binaryName]], + ], + ], + ]); + + $output = implode("\n", $output); + $this->assertStringContainsString('refused', $output); + $this->assertStringContainsString('extra.scanmephp.checksums', $output); + $this->assertStringNotContainsString('downloaded successfully', $output); + } + public function testUnremovableBinaryIsReportedInsteadOfDownloaded(): void { if (extension_loaded('scanmeqr')) { From a638bd3061f7af9d4dc0cb8284ec18a5e7004079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 19:20:53 +0200 Subject: [PATCH 7/7] test: name the binary in the refused-pin assertion (#63) Round 6 finding. The assertion matched "refused" and "extra.scanmephp.checksums", which the FFI branch prints on its own because that branch has no pin either way - so the test passed with the 64-hex validation removed, and would have started doing real HTTP transfers the moment the behaviour regressed. Asserting "No SHA-256 checksum configured for binary " is present on this branch and absent without the fix (verified in a scratch copy with only the shape validation removed). With that, all 12 new or changed test cases in this PR fail on origin/main; my previous commit message said eleven. This change was not reviewed by a seventh round, which the team lead ruled out. --- tests/Composer/PluginTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index ead732b..c27a456 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -196,8 +196,9 @@ public function testPackageInstallRefusesAWholeSha256SumLineBeforeDownloading(): ]); $output = implode("\n", $output); - $this->assertStringContainsString('refused', $output); - $this->assertStringContainsString('extra.scanmephp.checksums', $output); + // Name the binary: the FFI branch has no pin either way and would print + // "refused" on its own, so the generic words prove nothing here. + $this->assertStringContainsString('No SHA-256 checksum configured for binary ' . $binaryName, $output); $this->assertStringNotContainsString('downloaded successfully', $output); }