Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,17 @@ jobs:
- name: List artifacts
run: ls -la artifacts/

# sha256sum writes the standard "<digest> <name>" 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: |
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # "<digest> <binary name>"
```

```json
{
"extra": {
"scanmephp": {
"checksums": {
"X.Y.Z": {
"libscanme_qr-linux-glibc-x86_64.so": "<the digest, first column only>"
}
}
}
}
}
```

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.
Expand Down
14 changes: 9 additions & 5 deletions docs/release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down Expand Up @@ -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
23 changes: 18 additions & 5 deletions src/ChecksumManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
68 changes: 60 additions & 8 deletions src/Composer/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'));
Expand Down
Loading
Loading