diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 4d1ad2d..f8b8ec9 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -226,6 +226,17 @@ jobs: - name: List artifacts run: ls -la artifacts/ + # sha256sum writes the standard " " format, so a consumer can + # 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 + (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..8243651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ 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. 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). The file ships with + the first release that contains this change; earlier releases have no `checksums.txt`. ### Changed @@ -51,6 +56,35 @@ 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 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 instead of downloading when the file cannot be deleted — the + loaders probe these paths, so a leftover file would still be loaded. +- 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 +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 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 v0.5.1 has no binaries behind it: every extension build failed, so `Create diff --git a/README.md b/README.md index fbeb332..427e9f8 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,49 @@ 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 + +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 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 is a +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 +grep libscanme_qr-linux-glibc checksums.txt # " " +``` + +```json +{ + "extra": { + "scanmephp": { + "checksums": { + "X.Y.Z": { + "libscanme_qr-linux-glibc-x86_64.so": "" + } + } + } + } +} +``` + +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. + +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. + ### 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 945de19..13ea6f0 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -71,13 +71,17 @@ 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. + +`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/`. @@ -120,6 +124,6 @@ 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` - [ ] `qrcode-ext` mirror published - [ ] Milestone closed, next milestone exists diff --git a/src/ChecksumManager.php b/src/ChecksumManager.php index 9813cba..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 @@ -39,10 +44,16 @@ public function getChecksum(string $version, string $binaryName): ?string // 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] + $checksum = $this->checksums[$version][$binaryName] ?? $this->checksums['v' . $unprefixed][$binaryName] ?? $this->checksums[$unprefixed][$binaryName] ?? 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 @@ -54,10 +65,12 @@ 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 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 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..e47a9a2 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -154,10 +154,17 @@ 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.'); - @unlink($targetFile); + // 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 + ) . '.'); + if (!$this->removeUnverifiedBinary($targetFile)) { + return false; + } + $this->io->write(' Re-downloading the verified binary.'); } // Create binary directory @@ -225,10 +232,17 @@ 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.'); - @unlink($targetFile); + // 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 + ) . '.'); + if (!$this->removeUnverifiedBinary($targetFile)) { + return; + } + $this->io->write(' Re-downloading the verified library.'); } // Create binary directory @@ -264,6 +278,44 @@ 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; + } + + /** + * 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. 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) + */ + 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(' 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; + } + 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..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'), ], ], ], @@ -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 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'); $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,97 @@ public function testExistingBinaryIsValidWhenNoChecksumConfigured(): void } } + public function testEmptyChecksumValueIsNotAPin(): void + { + $tempDir = $this->createChecksumFixture([ + 'v0.4.4' => ['libscanme_qr-linux-glibc-x86_64.so' => ''], + ]); + + 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 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([ + 'v0.4.4' => ['libscanme_qr-linux-glibc-x86_64.so' => 12345], + ]); + + try { + $manager = new ChecksumManager($tempDir); + + $this->assertFalse($manager->hasChecksum('v0.4.4', 'libscanme_qr-linux-glibc-x86_64.so')); + } finally { + $this->cleanupFixture($tempDir); + } + } + + 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([ @@ -227,7 +319,7 @@ public function testExistingBinaryIsInvalidWhenFileMissing(): void } /** - * @param array>|null $checksums + * @param array $checksums */ private function createChecksumFixture(?array $checksums): string { diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index 0370a9d..c27a456 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,185 @@ 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('already exists', $output); - $this->assertStringNotContainsString('refused', $output); - $this->assertStringNotContainsString('Re-downloading', $output); - $this->assertSame('unverified-binary-content', file_get_contents($binaryPath)); + $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 once'); + } + + public function testPackageInstallSurvivesANonStringPin(): 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 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 => 12345]], + ], + ], + ], $plugin); + + $output = implode("\n", $output); + $this->assertStringContainsString('cannot be verified', $output); + $this->assertFileDoesNotExist($binaryPath); + $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); + // 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); + } + + 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('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)); + } finally { + chmod($binaryDir, 0700); + unlink($binaryPath); + } + } + + 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 @@ -136,7 +308,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); @@ -176,7 +349,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'); @@ -273,6 +447,8 @@ public function testPackageInstallWithDirectoryAtTargetPathFailsCleanly(): void } /** + * @param array $composerJson + * * @return list */ private function runPackageInstall(array $composerJson, ?Plugin $plugin = null): array