Skip to content

filter_var: implement FILTER_VALIDATE_EMAIL and FILTER_VALIDATE_DOMAIN - #7

Closed
byuufx wants to merge 1 commit into
francescotinti:mainfrom
byuufx:fix/filter-validate-email-domain
Closed

byuufx wants to merge 1 commit into
francescotinti:mainfrom
byuufx:fix/filter-validate-email-domain

Conversation

@byuufx

@byuufx byuufx commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Problem

filter_var() had no arm for filter ids 274 (FILTER_VALIDATE_EMAIL) or 277 (FILTER_VALIDATE_DOMAIN). Both fell through to the FILTER_DEFAULT catch-all, which returns the input unchanged, so every string validated:

var_dump(filter_var('not-an-email', FILTER_VALIDATE_EMAIL));   // phpr: string(12) "not-an-email"   PHP: bool(false)
var_dump(filter_var('a..b', FILTER_VALIDATE_DOMAIN));          // phpr: string(4) "a..b"           PHP: bool(false)

Fixes #6.

Change

crates/php-builtins/src/var.rs

  • 274 => calls a new validate_email, a port of php_filter_validate_email (ext/filter/logical_filters.c). It rejects input over 320 bytes, then requires a full match of the same PCRE pattern: regexp1, or regexp0 (\pL\pN, /u) under FILTER_FLAG_EMAIL_UNICODE. Both patterns are byte-identical to php-src, checked by script. Matching goes through php_runtime::preg the same way the FILTER_VALIDATE_REGEXP arm does.
  • 277 => calls a new validate_domain, a port of php_filter_validate_domain_ex. It ignores one trailing dot, then checks the 253-byte total, no empty labels, labels of at most 63 bytes, and the alnum/- rules under FILTER_FLAG_HOSTNAME. The C code reads one byte past the label (s[1]), which sees the NUL terminator or the dropped trailing dot. at(i) reproduces that without going out of bounds.
  • On a miss, both arms return default_or_miss(), so the default option and FILTER_NULL_ON_FAILURE behave as in PHP.

No other arm or helper changed.

Tests

crates/php-builtins/tests/builtins.rs: two tests next to filter_validate_regexp_miss_and_null_on_failure. Expected output is taken from PHP 8.5.11 (php:8.5-cli).

  • filter_validate_email covers:
    • valid inputs: plain, +tag with subdomains, [IPv4] and [IPv6:…] literals
    • invalid inputs: no @, empty, a@b, leading space, .., trailing dot, a domain starting with -, a quoted local part with a space, a 65-byte local part, a 64-byte domain label, over 320 bytes
    • a Unicode local part with and without FILTER_FLAG_EMAIL_UNICODE
    • FILTER_NULL_ON_FAILURE and default
  • filter_validate_domain covers 16 inputs, each checked with and without FILTER_FLAG_HOSTNAME:
    • edge inputs: empty, ., a trailing dot, leading and double dots
    • characters: -/_/space at the edges of a label
    • length limits: a 63- and 64-byte label, exactly 253 bytes with a trailing dot, 254 bytes
    • FILTER_NULL_ON_FAILURE and default
cargo test --release -p php-builtins          # PHP_ORACLE = PHP 8.5 (php:8.5-cli)
unittests src/lib.rs      23 passed
tests/builtins.rs        393 passed   (incl. filter_validate_email, filter_validate_domain)
tests/differential.rs      1 passed
tests/vm_builtins.rs       7 passed

I also checked this on a small app that validates emails with filter_var. Its PHPUnit suite went from 27/29 to 29/29 on phprust, and an HTTP check against php -S (same requests, compared response by response) went from 18/19 to 19/19 identical.

Both filter ids had no arm in filter_var and fell through to the
FILTER_DEFAULT catch-all, so every string validated.

- 274: port of php_filter_validate_email (320-byte cap, then the
  logical_filters.c pattern; the \pL\pN /u variant under
  FILTER_FLAG_EMAIL_UNICODE).
- 277: port of php_filter_validate_domain_ex (FILTER_FLAG_HOSTNAME
  label rules).

A miss returns default_or_miss(), honouring the `default` option and
FILTER_NULL_ON_FAILURE. Tests are oracle-verified against PHP 8.5.11.

Fixes francescotinti#6
francescotinti pushed a commit that referenced this pull request Oct 29, 2026
…ken from a pipe in 02b5a47)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
francescotinti pushed a commit that referenced this pull request Nov 2, 2026
…ilter_validate.rs (emend of PR #7)

tests/builtins.rs sits exactly at its `loc_dente` cap (4772 lines, REGOLE §8:
caps rise only in a promotion commit), so the +34 lines of PR #7 made the
dente test fail. The two tests move verbatim to a new integration-test file
(unlisted files are capped at 2000) with the same `out` helper;
tests/builtins.rs is back to 4772 lines. var.rs is the PR's, untouched.

Expectations re-checked against the project oracle (php 8.5.7 CLI): both
outputs byte-identical; the two PCRE patterns are byte-identical to php-src
`regexp0`/`regexp1` (ext/filter/logical_filters.c).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@francescotinti

Copy link
Copy Markdown
Owner

Thanks — merged into main as a cherry-pick of your commit (authorship preserved) plus one follow-up:

  • your commit: 662c161a (var.rs untouched: both arms and helpers exactly as you wrote them)
  • follow-up: e3e77744 — test(php-builtins): move the filter_var email/domain tests to tests/filter_validate.rs

Why the follow-up: tests/builtins.rs sits exactly at its loc_dente cap (4772 lines; php-runtime/tests/loc_dente.rs), and caps rise only in a promotion commit (php-rust/REGOLE.md §8), so the +34 lines made the dente test fail. Your two tests moved verbatim to a new integration-test file with the same out helper.

Independent checks here: the two PCRE patterns are byte-identical to php-src regexp0/regexp1 (1097/1072 bytes, scripted compare against ext/filter/logical_filters.c 8.5.7), validate_domain mirrors _php_filter_validate_domain line by line, and the oracle (php 8.5.7 CLI) reproduces both expected outputs byte for byte. Battery and corpus gate results in the commit/verbal of session S-187.

Closing this PR since the commits are on main via cherry-pick; #6 is fixed by them.

francescotinti pushed a commit that referenced this pull request Nov 2, 2026
… into s186-firma — REGOLE §5 frozen corpus 1407, §3.36 trait __CLASS__/__METHOD__ catalogued, NEXT scoreboard/pre-flight/openings, session verbal

Gates on the triage tree (dev-release): build rc=0, loc_dente rc=0 with no
cap raised, battery 1755/0 (+3 new dente), corpus-gate rc=0 (names == frozen
1407 x2 modes, content == golden 1404, off/on zero diffs), oracle diff
IDENTICAL on the filter_var and enum fixtures. Route B stays dead (S-186 bis
rc=4). S-188 order unchanged: O(1) cure v2 -> pin s184 -> t26/t28 pair ->
merge on user signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

filter_var() with FILTER_VALIDATE_EMAIL / FILTER_VALIDATE_DOMAIN accepts any string (falls through to FILTER_DEFAULT)

2 participants