Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 8 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
5 changes: 5 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
<code><![CDATA[$emailAddress['address_temp']]]></code>
<code><![CDATA[$emailAddress['address_temp']]]></code>
<code><![CDATA[$emailAddress['address_temp']]]></code>
<code><![CDATA[$emailAddress['address_temp']]]></code>
<code><![CDATA[$emailAddress['address_temp']]]></code>
<code><![CDATA[$emailAddress['address_temp']]]></code>
<code><![CDATA[$emailAddress['in_angle_addr']]]></code>
<code><![CDATA[$emailAddress['invalid']]]></code>
<code><![CDATA[$emailAddress['local_part_parsed']]]></code>
<code><![CDATA[$emailAddress['local_part_parsed']]]></code>
<code><![CDATA[$emailAddress['name_parsed']]]></code>
<code><![CDATA[$emailAddress['name_parsed']]]></code>
</PossiblyUndefinedArrayOffset>
Expand Down
29 changes: 26 additions & 3 deletions src/Parse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'])) {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/ParseErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '@'. */
Expand Down
30 changes: 30 additions & 0 deletions tests/ParseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 11 additions & 11 deletions tests/testspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
Loading