Skip to content

fix: stop treating the payload "0" as empty data (#75) - #267

Merged
s2x merged 2 commits into
mainfrom
fix/issue-75-bug-empty-data-rejects-the-valid-payload
Oct 4, 2026
Merged

s2x merged 2 commits into
mainfrom
fix/issue-75-bug-empty-data-rejects-the-valid-payload

Conversation

@s2x

@s2x s2x commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

What changed

empty("0") is true in PHP, so both emptiness checks rejected the one-character payload "0"
even though FastEncoder, FfiEncoder, NativeEncoder and the C++ core all encode it. Encoder
and QRCode now compare against '' explicitly.

QRCode still requires a valid URL, so "0" is reported there as
Invalid URL provided: "0" rather than as empty data — the old message was factually wrong.
The empty check stays first, so '' still yields Data cannot be empty and nothing else moves.
Non-URL payloads go through an encoder directly.

The other backends were swept and needed no change: FastEncoder already uses
strlen($url) === 0, FfiEncoder uses $data === '', NativeEncoder passes ZSTR_LEN to
C, and clib/src/encoder.cpp checks len == 0.

How it was tested

The "0" payloads are pinned by three new rows in tests/fixtures/qr_reference.csv. That file is
read by two independent mechanisms: tests/QrReferenceTest.php compares every row against
Encoder, FastEncoder and FfiEncoder, and clib/tests/test_csv_fixtures.cpp compares
the same rows against the C++ core — so one row pins four backends.

  • Fixture generation. The 441 module bits per row were produced by a standalone C++ program
    linked against clib/src/encoder.cpp, with no PHP in the process, then compared against all
    three PHP backends: 3 payloads × 3 backends = 9 comparisons, all byte-identical. The fixture is
    not "whatever PHP now produces".
  • The fixture has teeth. test_csv_fixtures accepts the new rows (1772 → 1775 data rows,
    0 failures) and rejects a copy of the "0" row with a single module bit flipped
    (1 tests, 1 failures, exit 1).
  • tests/EncoderTest.php: asserts v1 / size 21 for "0", "00" and "0" repeated ten times
    (what the payload actually determines), and keeps the empty-string case throwing
    Data cannot be empty.
  • tests/QRCodeTest.php: the empty-URL case, plus a test pinning "0" to
    Invalid URL provided: "0".

Red/green

Red was reproduced by reverting only src/ to the base commit 9c93e75, with the new tests and
fixtures in place:

$ git checkout 9c93e75 -- src/Encoder.php src/QRCode.php
$ vendor/bin/phpunit --filter 'data set "0' tests/QrReferenceTest.php

 ✘ Encoder matches reference with data set "0 ECL=L"
   CrazyGoat\ScanMePHP\Exception\InvalidDataException: Data cannot be empty
   src/Encoder.php:87
 ✔ Encoder matches reference with data set "00 ECL=L"
 ✔ Encoder matches reference with data set "0000000000 ECL=L"
 ✔ Fast encoder matches reference with data set "0 ECL=L"
 ✔ Ffi encoder matches reference with data set "0 ECL=L"
 ERRORS! Tests: 9, Assertions: 16, Errors: 1.

Red lands on exactly Encoder matches reference for "0" while FastEncoder and FFI pass — the
backend disagreement, expressed in the repo's own terms. Note that only the "0" row goes red;
"00" and "0"×10 were never broken, so those rows are guard rails rather than reproducers.

The other new tests on base code:

$ vendor/bin/phpunit tests/EncoderTest.php tests/QRCodeTest.php
 ✘ Encode accepts zero only payload with data set "single "0""
 ✘ Zero payload is reported as invalid url not empty data
 Tests: 32, Assertions: 75, Errors: 1, Failures: 1.

testEmptyUrlThrowsEmptyData passes both before and after the fix — '' behaviour is unchanged
by design — so it is deliberately not claimed as a red case.

Green with the fix restored:

  • bin/lint.sh — All checks passed. composer validate --strict — valid.
  • composer test — 5487 tests, 20153 assertions, OK. (8 deprecations + 4 PHPUnit deprecations
    are pre-existing imagedestroy() calls; the new tests add none.)
  • ctest in clib/build — 3/3 passed, with the extended CSV.

Review round 2

Both blocking review findings were in the tests and are addressed:

  • F1 — the parity test was a tautology. Accepted and deleted rather than repaired.
    Encoder::encode() delegates to FastEncoder::encodeVersion() for every version ≤ 27 and all
    three payloads resolve to v1, so both sides of the comparison were the same call; the test
    stayed green even with FastEncoder made to return a wrong matrix. Comparing two PHP encoders
    cannot work while that fast path exists, so the coverage moved to the fixture rows above. The
    misleading docblock sentence was deleted with the test.
  • F2 — testEncodeAcceptsZeroOnlyPayload pinned nothing. Accepted. assertInstanceOf was
    guaranteed by the return type, the version check held for any constructible Matrix, and
    assertCount($matrix->getSize(), $matrix->getData()) counted rows (nested bool[][]), so it
    would fail confusingly with a 21-vs-441 diff on any getData() refactor. All three are gone.

Non-blocking points: the CHANGELOG "Both sites" wording is fixed, and the PR body's red/green
claim now names the specific cases that go red instead of implying all of them. src/Builder.php:39
keeps its empty() — it tests shell_exec() output of which/where, never a payload. The
README does not document that QRCode is URL-only; that gap is being filed separately.

Checklist

  • Tests added or updated, and all checks pass
  • CHANGELOG.md updated under [Unreleased]
  • Documentation updated (no doc changes needed; the QRCode-is-URL-only gap is filed separately)
  • Everything is written in English
  • The PR title is a Conventional Commit (feat: ..., fix: ...)

Fixes #75

s2x added 2 commits October 4, 2026 17:38
`empty("0")` is true in PHP, so both emptiness checks rejected the
one-character payload "0" even though FastEncoder, FfiEncoder,
NativeEncoder and the C++ core all encode it.

Encoder and QRCode now compare against '' explicitly. QRCode still
requires a valid URL, so "0" is reported as an invalid URL there
instead of as empty data, which is what it is.

Adds tests/EncoderTest.php covering "0", "00" and "0" repeated ten
times, plus an Encoder/FastEncoder parity check, and two QRCode tests
for the empty and zero payloads.
The parity test added for #75 was a tautology: Encoder::encode()
delegates to FastEncoder::encodeVersion() for every version <= 27, and
"0", "00" and "0" repeated all resolve to v1, so both sides of the
comparison were the same call and the test stayed green even when
FastEncoder was made to return a wrong matrix.

Comparing two PHP encoders cannot work while that fast path exists, so
the payloads move to the mechanism that already pins module bits: three
rows in tests/fixtures/qr_reference.csv, checked by QrReferenceTest
against Encoder, FastEncoder and FfiEncoder, and by
clib/tests/test_csv_fixtures.cpp against the C++ core.

The 441 bits per row were generated by a C++ program linked against the
C++ core with no PHP in the process, then compared against all three PHP
backends (9 comparisons, byte-identical). The C++ fixture reader accepts
the rows and rejects a row with one flipped module bit.

The surviving EncoderTest assertions now pin what the payload actually
determines, v1 at ECL L, instead of counting rows of getData().
@s2x
s2x merged commit 06ee3d8 into main Oct 4, 2026
10 checks passed
@s2x
s2x deleted the fix/issue-75-bug-empty-data-rejects-the-valid-payload branch October 4, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: empty($data) rejects the valid payload "0" as empty

1 participant