Skip to content

Fix two silent-corruption bugs and four conformance gaps - #154

Merged
yoeunes merged 8 commits into
mainfrom
conformance-fixes
Sep 2, 2026
Merged

yoeunes merged 8 commits into
mainfrom
conformance-fixes

Conversation

@yoeunes

@yoeunes yoeunes commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator
Q A
Bug fix? yes
New feature? no
BC Break? no
Deprecations? no
Fixed tickets -
License MIT

Two of these lose or corrupt what the caller gave us, and four are patterns
PCRE accepts that this library did not — or the other way round. Each commit
carries the test that would have caught it.

Silent corruption

  • A pattern that is not valid UTF-8 lost everything after a \Q...\E run or a
    (?#...) comment. Those two were read with a UTF-8 regex, and PCRE's refusal
    was taken for the end of the pattern: /\Q\xFFabc\E]/ came back as //, with
    no error.
  • Recompiling a version condition produced a pattern PCRE refuses:
    (?(VERSION>=10.4)y|n) came back with one parenthesis too many. Nothing
    compiled a conditional back out, so it went unseen; there is now a case per
    kind of condition.
  • The language server's quick fixes wrapped the replacement in quotes without
    escaping, so applying one to /it's/ left the file unparseable.

Conformance

  • (*atomic:((a))) and (*pla:((a)b(c))) were refused; a verb wraps as many
    brackets as its sub-pattern needs.
  • The J modifier reached past the group carrying it, so (?J:(?<n>a))(?<n>b)
    was accepted where PCRE refuses it.
  • (?(VERSION=10.4)...) is read; PCRE takes that spelling too.
  • (?'name'x) and (*sr:...) came back rewritten as (?<name>x) and
    (*script_run:...). The round trip already kept the author's optional
    escapes; it keeps these too.

Robustness

  • Scanning Unicode asked for mbstring without checking, the only place in the
    package that required it.

Every new case is checked against what PCRE answers for the same pattern.
6670 tests, task lint green, and the report for the 12 929 corpus patterns is
byte-identical.

A pattern that is not valid UTF-8 is tokenized byte by byte, the way PCRE
compiles it without /u. Two places did not follow: the quoted run and the
comment were read with a regex of their own, and both asked PCRE for UTF-8.

On a pattern holding a stray byte those matches fail, and the failure was
taken for "nothing left to read": the lexer left the mode, jumped to the end
of the pattern, and everything after it disappeared. "/\Q\xFFabc\E]/" —
which PCRE accepts — came back as "//". No error, no warning, an empty
pattern.

Both now read the subject the way the pattern itself is read, and a failure
that is a real PCRE error is reported instead of swallowed.

The two tests that covered the old behaviour asserted the truncation. They
assert the error now, and a new test walks byte-mode patterns through the
lexer and back out.
The same mistake as the recursion condition, one node along: the version
condition wrote its own "(?(...)" and the conditional wrapped it again, so
"(?(VERSION>=10.4)y|n)" came back as "(?((?(VERSION>=10.4))y|n)" — one
parenthesis too many and a pattern PCRE refuses.

The node returns the condition alone now, as every other kind does, and the
conditional writes the parentheses.

It went unnoticed because nothing compiled a conditional back out. There is
now a case per kind of condition — numbered, named, recursion, version,
the four lookarounds, define, and a conditional with no else branch — each
checked against the pattern it came from and against PCRE.

"(?(VERSION=10.4)...)" is read too, which PCRE accepts and the reader did
not.
A quick fix hands the editor the text to put in place of the pattern, and
built it by wrapping the new pattern in single quotes. A regex is free to
hold one — "/it's/" is a fine pattern — and applying the fix to such a
pattern left the file unparseable.

The replacement is written as a PHP literal, which escapes what has to be.

The binary also stops announcing a version of its own: --version said 1.0.0
while the library was at 1.3.0, the same drift the handshake had.
Encoding a block of code points asked mb_convert_encoding without checking
that mbstring is there. Everywhere else the package works without it —
CodePointHelper falls back to intl and then to writing the UTF-8 bytes
itself — so a process without the extension would have hit a fatal here and
nowhere else.

The conversion is used where it exists, since it does the whole block at
once, and the same encoding is done by hand where it does not. A test walks
both across every width change in UTF-8 and around the surrogates.
"J" lets two groups share a name, and like every inline modifier it reaches
only to the end of the group that carries it. The parser turned it on and
never turned it back off, so a name repeated well outside the reach of the
"(?J)" was accepted — "(?J:(?<n>a))(?<n>b)" among them, which PCRE refuses.

It is saved and restored the way the extended mode already was, at the two
places a group ends.

Six cases pin where the modifier reaches, each checked against PCRE's answer
for the same pattern before the parser is asked.
The alphabetic verbs carry a sub-pattern — "(*atomic:...)", "(*pla:...)",
"(*sr:...)" — and the token pattern that matched them allowed brackets one
level deep. "(*atomic:((a)))" is a pattern PCRE takes and this one refused,
with an error about a quantifier, since the verb fell apart into pieces the
parser then had to make sense of.

The body is matched by recursion instead, so it nests as deep as the
sub-pattern does.

The corpus report is unchanged: nothing in twelve thousand real patterns
was reading a verb the old way.
…n with

PCRE names a group four ways — "(?<n>", "(?'n'", "(?P<n>", "(?P\"n\"" — and
writes a script run two — "(*sr:", "(*script_run:". They mean the same
thing, so the tree keeps only the name, and the compiler picked one spelling
for everyone.

Rewriting a pattern the author did not ask to have rewritten is what the
round trip is meant to avoid; it already avoided it for optional escapes and
for the whitespace /x ignores. Both are now read back from the source, and
only when what is written names the very group being compiled.

The fidelity test gains the six spellings. Two cases move with it: the
quoted Python spellings come back as they were written, and since PCRE
refuses those spellings outright, they leave the list of patterns whose
compiled form PCRE is asked to accept.
Rector cannot resolve a PHPUnit assertion called through self:: and stops on
the file rather than analysing it.
@yoeunes
yoeunes merged commit fb32b73 into main Sep 2, 2026
24 checks passed
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.

1 participant