From b3c6a4ce408f0dcda7c255a734d61f04a478fe7e Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Wed, 22 Jul 2026 00:33:12 -0700 Subject: [PATCH] Reject unclosed delimiters, quoted-string abutment, and nested comment imbalance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural over-acceptance fixes surfaced by gold-standard differential testing (dominicsayers/isemail corpus). Reduces the corpus false-accept set from 29 to 26. - Unclosed domain literal (test@[1.2.3.4) is rejected — the end-of-input unterminated-delimiter check is keyed on parser state rather than quote_temp, so unclosed brackets, comments, and obs-routes are all caught. - Unbalanced nested comment (((comment)test@) is rejected — a leading comment now opens at nest level 1, matching the in-address entry. - atext or a second quote abutting a quoted-string ("test"test@, "test""test"@) is rejected via a new AtextAfterQuotedString error code; "word".atom stays valid. Control-char-in-domain was already rejected (a corpus encoding artifact). The remaining CR/LF-whitespace and trailing-root-dot divergences are deliberate design choices, tracked in ROADMAP for a decision. 94 -> 95 tests. --- CHANGELOG.md | 6 ++++++ ROADMAP.md | 16 ++++++++-------- psalm-baseline.xml | 5 +++++ src/Parse.php | 29 ++++++++++++++++++++++++++--- src/ParseErrorCode.php | 4 ++++ tests/ParseTest.php | 30 ++++++++++++++++++++++++++++++ tests/testspec.yml | 22 +++++++++++----------- 7 files changed, 90 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b72315..61f86de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed +Structural over-acceptance bugs surfaced by gold-standard differential testing (dominicsayers/isemail corpus): +- **Unclosed domain literal** — `test@[1.2.3.4` (no closing `]`) is now rejected (`UnterminatedSquareBracket`). The end-of-input unterminated-delimiter check is now keyed on the parser state rather than on `quote_temp`, so unclosed brackets, comments, and obs-routes are all caught. +- **Unbalanced nested comment** — a leading comment now opens at nest level 1 (matching the in-address entry), so `((comment)test@…` is no longer treated as closed after a single `)`. +- **atext/quote abutting a quoted-string** — `"test"test@…` and `"test""test"@…` are now rejected (`AtextAfterQuotedString`, a new `ParseErrorCode`); a quoted-string is a whole word (RFC 5322 §3.2.4). `"word".atom` (obs `word "." word`) stays valid. + ## [3.5.0] Local-part correctness and configurability. **Heads-up:** `rfc5322()` is now stricter about dot placement (see Changed) — a behavior change for callers relying on the previous permissive dot handling; the old behavior is one builder call away. diff --git a/ROADMAP.md b/ROADMAP.md index 4e9d8e2..ef0b92c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,14 +90,14 @@ Not tied to a specific release; picked up as time allows. Differential testing against the `dominicsayers/isemail` reference corpus (164 cases) and a reference RFC validator surfaced a set of over-acceptance edge cases — inputs the parser currently treats as valid that the reference standard rejects. Clustered by root cause, in rough priority order: -- [ ] **Comment (CFWS) parsing** — unclosed comments (`((comment)test@`, `test@iana.org(comment\`) and atext after a comment in the local part (`test(comment)test@`) are wrongly accepted. RFC 5322 §3.2.2: a comment must be balanced, and CFWS may not sit between atext runs of a dot-atom. -- [ ] **Quoted-string boundaries** — atext adjacent to a quoted string (`"test"test@`) and consecutive quoted strings (`"test""test"@`) are wrongly accepted. A quoted-string is a whole `word`; nothing may abut it without a separating dot. -- [ ] **Unclosed domain literal** — `test@[1.2.3.4` (missing `]`) is wrongly accepted. -- [ ] **CR/LF & folding-whitespace strictness** — trailing/embedded bare CR or LF and malformed CRLF folding (`test@iana.org\r`, `...\r\n\r\n`) are accepted. Partly intentional (the batch parser trims surrounding whitespace), so decide per-mode: strict presets should reject; the lenient/batch path may keep trimming. Document the chosen contract. -- [ ] **Control character in domain** — a C0 control in the domain (`test@\x07.org`) should be rejected. -- [ ] **Trailing domain dot** — `test@iana.org.` is accepted as the RFC 5321 §2.3.5 root-label dot; the reference corpus flags it. Likely keep (defensible), but confirm and document the divergence rather than leave it implicit. - -Approach: one PR per cluster, each adding the failing corpus cases as regression tests. The comparison harness is a local dev tool (not a CI gate) until the disagreements are triaged, since ~20 reference cases currently diverge. +- [x] **Quoted-string boundaries** — atext adjacent to a quoted string (`"test"test@`) and consecutive quoted strings (`"test""test"@`) are now rejected (`AtextAfterQuotedString`). `"word".atom` (obs `word "." word`) stays valid. +- [x] **Unclosed domain literal** — `test@[1.2.3.4` now rejected; the end-of-input unterminated-delimiter check is keyed on parser state, catching unclosed brackets/comments/obs-routes. +- [x] **Control character in domain** — already rejected (a corpus artifact from the Unicode Control-Pictures encoding, not a real gap). +- [~] **Comment (CFWS) parsing** — unbalanced nested comment (`((comment)test@`) now rejected. Remaining: backslash-escaped parens (`(comment\)test@` — `\)` is a quoted-pair, so the comment is still open) and atext directly after a mid-local-part comment (`test(comment)test@`). RFC 5322 §3.2.2. +- [ ] **CR/LF & folding-whitespace — decision needed.** ~16 corpus cases: bare CR/LF (`test@iana.org\r`) and CRLF folding (`...\r\n\r\n`) leading/trailing an address are accepted because the parser treats CR/LF as whitespace and trims surrounding whitespace — **intentional for batch parsing** (addresses read from lines of a file). Options: (a) keep as-is and document the divergence, (b) reject bare CR/LF and malformed folding only in the strict presets (`rfc5321`/`rfc5322`) while the batch/lenient path keeps trimming. This changes core whitespace handling, so it needs a deliberate design call before implementing. +- [ ] **Trailing domain dot** — `test@iana.org.` is accepted as the RFC 5321 §2.3.5 root-label dot; the corpus flags it. Intentional and defensible — keep, documented here. + +Approach: the comparison harness stays a local dev tool (not a CI gate) since the CR/LF and trailing-dot cases are deliberate design choices, not bugs. Fixed clusters carry regression tests in `tests/ParseTest.php`. **Static analysis:** - [x] PHPStan level 6 → 8 — tighter generics and inference; required four small nullable-return guards (`idn_to_ascii`, `mb_split`, `file_get_contents`) and one local docblock shape on `parseMultiple()`. diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 9451d0d..49f2921 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -16,8 +16,13 @@ + + + + + diff --git a/src/Parse.php b/src/Parse.php index 956b624..de2f6a5 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -321,6 +321,10 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } elseif ('(' == $curChar) { $emailAddress['original_address'] .= $curChar; $state = self::STATE_COMMENT; + // A leading comment opens at nest level 1 (matches the + // STATE_ADDRESS entry); without this an unbalanced nested + // comment like "((x)" would appear closed after one ")". + $commentNestLevel = 1; break; } @@ -332,6 +336,18 @@ public function parse(string $emails, bool $multiple = true, string $encoding = $emailAddress['original_address'] .= $curChar; } + if ($emailAddress['after_closing_quote']) { + $emailAddress['after_closing_quote'] = false; + // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot + // (obs word.word), '@', angle brackets, CFWS, or a separator may + // follow it — atext or a second quote directly abutting it is invalid. + if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { + $emailAddress['invalid'] = true; + $emailAddress['invalid_reason'] = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; + $emailAddress['invalid_reason_code'] = Err::AtextAfterQuotedString; + } + } + if ('(' == $curChar) { // Handle comment $state = self::STATE_COMMENT; @@ -761,6 +777,7 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // this flag from address_temp_quoted when '@' is reached. $state = self::STATE_ADDRESS; $emailAddress['local_part_quoted'] = true; + $emailAddress['after_closing_quote'] = true; } } else { $emailAddress['quote_temp'] .= $curChar; @@ -835,14 +852,17 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } } - // End-of-input reached with an unclosed delimiter — mark invalid with a descriptive reason - if (!$emailAddress['invalid'] && $emailAddress['quote_temp']) { + // End-of-input reached still inside a delimiter (quote, comment, domain + // literal, or obs-route) — the construct was never closed. Keyed on the + // parser state rather than quote_temp, since bracket/comment content is + // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). + if (!$emailAddress['invalid'] && in_array($state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { $emailAddress['invalid'] = true; [$emailAddress['invalid_reason'], $emailAddress['invalid_reason_code']] = match ($state) { self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], - default => ['Unterminated quoted section', Err::IncompleteAddress], + self::STATE_OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], }; } if (!$emailAddress['invalid'] && ($emailAddress['address_temp'] || $emailAddress['quote_temp'])) { @@ -941,6 +961,9 @@ private function buildEmailAddressArray(): array 'local_part_quoted' => false, 'name_quoted' => false, 'address_temp_quoted' => false, + // True for exactly the character after a closing quote, so atext / a + // second quote directly abutting a quoted-string can be rejected. + 'after_closing_quote' => false, 'quote_temp' => '', 'address_temp' => '', 'address_temp_period' => 0, diff --git a/src/ParseErrorCode.php b/src/ParseErrorCode.php index 2be6e61..1bd7f58 100644 --- a/src/ParseErrorCode.php +++ b/src/ParseErrorCode.php @@ -136,6 +136,10 @@ enum ParseErrorCode: string /** C1 control character inside a quoted-string (RFC 6530 §10.1). */ case C1ControlInQuotedString = 'c1_control_in_quoted_string'; + /** atext or a second quoted-string immediately follows a quoted-string with no + * separating dot — a quoted-string is a whole word (RFC 5322 §3.2.4). */ + case AtextAfterQuotedString = 'atext_after_quoted_string'; + // --- Domain errors --- /** Empty domain after '@'. */ diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 1a09032..49b2336 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -339,6 +339,36 @@ public function testLengthLimitsCanBeDisabled(): void ); } + /** + * Structural over-acceptance fixes surfaced by gold-standard differential + * testing (dominicsayers/isemail corpus). Each input is malformed and must be + * rejected; well-formed neighbours must stay valid. + */ + public function testStructuralOverAcceptanceRejections(): void + { + $p = new Parse(null, ParseOptions::rfc5322()); + $Err = \Email\ParseErrorCode::class; + + // Unclosed domain literal (no closing ']'). + $this->assertSame($Err::UnterminatedSquareBracket, $p->parseSingle('test@[1.2.3.4')->invalidReasonCode); + $this->assertFalse($p->parseSingle('test@[1.2.3.4]')->invalid); + + // Unbalanced nested comment ("((x)" is not closed by one ")"). + $this->assertSame($Err::UnterminatedComment, $p->parseSingle('((comment)test@iana.org')->invalidReasonCode); + $this->assertFalse($p->parseSingle('(comment)test@iana.org')->invalid); // leading CFWS ok + $this->assertFalse($p->parseSingle('test@iana.org(comment)')->invalid); // trailing CFWS ok + $this->assertFalse($p->parseSingle('((a)(b))test@iana.org')->invalid); // balanced nesting ok + + // A quoted-string is a whole word: atext or a second quote may not abut it. + $this->assertSame($Err::AtextAfterQuotedString, $p->parseSingle('"test"test@iana.org')->invalidReasonCode); + $this->assertSame($Err::AtextAfterQuotedString, $p->parseSingle('"test""test"@iana.org')->invalidReasonCode); + $this->assertFalse($p->parseSingle('"test".x@iana.org')->invalid); // obs word.word ok + $this->assertFalse($p->parseSingle('"test"@iana.org')->invalid); + + // Control character in the domain. + $this->assertTrue($p->parseSingle("test@\x07.org")->invalid); + } + public function testStrictIdnaAcceptsValidIdn(): void { // "bücher.de" is a well-formed IDNA label — valid under strict IDNA2008. diff --git a/tests/testspec.yml b/tests/testspec.yml index e0d5452..ad27fb7 100644 --- a/tests/testspec.yml +++ b/tests/testspec.yml @@ -530,14 +530,14 @@ original_address: 't"na"me@[10.0.10.45]' name: '' name_parsed: '' - local_part: '"tname"' - local_part_parsed: tname - domain_part: '[10.0.10.45]' + local_part: '""' + local_part_parsed: '' + domain_part: '' domain: '' domain_ascii: null - ip: 10.0.10.45 + ip: '' invalid: true - invalid_reason: "IP address invalid: '10.0.10.45' does not appear to be a valid IP address in the global range" + invalid_reason: 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it' comments: [] - address: tname@asdf.ghjkl.com @@ -575,17 +575,17 @@ result: address: '' simple_address: '' - original_address: 't"na"me@[10.0.10.45]' + original_address: 't"na"me@[10.0.10.45] tname@asdf.ghjkl.com, tname-test2@asdf.ghjkl.com' name: '' name_parsed: '' - local_part: '"tname"' - local_part_parsed: tname - domain_part: '[10.0.10.45]' + local_part: '""' + local_part_parsed: '' + domain_part: '' domain: '' domain_ascii: null - ip: 10.0.10.45 + ip: '' invalid: true - invalid_reason: "IP address invalid: '10.0.10.45' does not appear to be a valid IP address in the global range" + invalid_reason: 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it' comments: [] - emails: 't(comment with spaces !!!)name@[10.0.10.45] tname@asdf.ghjkl.com, tname-test2@asdf.ghjkl.com'