From ba92ce6400e5497161aed66bee0cddd8ccba802d Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 23 Aug 2026 21:47:01 -0400 Subject: [PATCH 01/11] Raise the PHP floor to ^8.5 with the CI matrix in the same commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 ships the Chrono rule family and the owner chose to target the 8.5 runtime for it rather than hold the ^8.4.1 floor. Manifest, all five workflow pins, the 13.x-dev canary matrix, README and installation doc move together — a floor changed without its matrix is how six repos sat red after the org's first adoption sweep. --- .github/workflows/benchmark.yml | 2 +- .github/workflows/laravel-13-dev.yml | 6 +++--- .github/workflows/phpstan.yml | 2 +- .github/workflows/rector.yml | 2 +- .github/workflows/release-benchmark.yml | 2 +- .github/workflows/run-tests.yml | 2 +- README.md | 2 +- UPGRADING.md | 6 ++++++ composer.json | 2 +- docs/installation.md | 2 +- 10 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 86b6618..611aec5 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -24,7 +24,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Install base dependencies diff --git a/.github/workflows/laravel-13-dev.yml b/.github/workflows/laravel-13-dev.yml index ef01902..daf8ae1 100644 --- a/.github/workflows/laravel-13-dev.yml +++ b/.github/workflows/laravel-13-dev.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 - name: P8.4 - L-13.x-dev - testbench-newest-dev - ubuntu-latest + name: P8.5 - L-13.x-dev - testbench-newest-dev - ubuntu-latest steps: - name: Checkout code @@ -44,7 +44,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.4 + php-version: 8.5 extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo coverage: none @@ -77,7 +77,7 @@ jobs: Nightly \`laravel/framework:13.x-dev\` run failed. - **Run:** <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}> - - **Matrix:** PHP 8.4 × laravel:13.x-dev × testbench:*@dev × prefer-stable × ubuntu-latest + - **Matrix:** PHP 8.5 × laravel:13.x-dev × testbench:*@dev × prefer-stable × ubuntu-latest - **Commit:** ${{ github.sha }} - **Install outcome:** ${{ steps.install.outcome }} - **Test outcome:** ${{ steps.tests.outcome }} diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index e65ae5f..131f708 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -28,7 +28,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml index 61731d4..4e657bf 100644 --- a/.github/workflows/rector.yml +++ b/.github/workflows/rector.yml @@ -29,7 +29,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/release-benchmark.yml b/.github/workflows/release-benchmark.yml index d5a90ae..ee53ea6 100644 --- a/.github/workflows/release-benchmark.yml +++ b/.github/workflows/release-benchmark.yml @@ -24,7 +24,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Install dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 16a3308..e47dd28 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - php: [8.5, 8.4] + php: [8.5] laravel: [13.*] stability: [prefer-lowest, prefer-stable] include: diff --git a/README.md b/README.md index 06cc925..d00831c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ > Write Laravel validation rules with IDE autocompletion instead of memorising string syntax — each rule type exposes only the methods that apply to it, `each()` and `children()` keep parent and child rules in one place, and large wildcard arrays validate tens of times faster — see the [benchmarks](docs/performance.md). -Targets PHP `^8.4.1` on Laravel `^13`. +Targets PHP `^8.5` on Laravel `^13`. ```php // Before diff --git a/UPGRADING.md b/UPGRADING.md index f420a3c..2e9acde 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -4,6 +4,12 @@ Breaking changes, and what to do about them. Versions not listed here need no ac ## Unreleased +### The PHP floor is now `^8.5` + +The package requires PHP 8.5. The `Rules/Chrono/` family and the 1.0 platform work target the +8.5 runtime, and the test matrix runs 8.5 only. Applications on PHP 8.4 must upgrade the runtime +before taking this version; no code changes are required beyond that. + ### `ArrayRule::getEachRules()` is removed It was an exact alias of `getEachKeyedRules()` — same body, wider advertised type. Replace diff --git a/composer.json b/composer.json index 7e01dc0..b78ce0e 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ "docs": "https://opensource.simtabi.com/documentation/laranail/validation/" }, "require": { - "php": "^8.4.1 || ^8.5", + "php": "^8.5", "illuminate/contracts": "^13.0", "illuminate/http": "^13.0", "illuminate/support": "^13.0", diff --git a/docs/installation.md b/docs/installation.md index 728a6ee..cecbc49 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,7 +8,7 @@ You can install the package via composer: composer require laranail/validation ``` -Requires PHP 8.4.1+ and Laravel 13. +Requires PHP 8.5+ and Laravel 13. > The package is pre-1.0. The public surface is settling, so pin a constraint you are happy to > review — `^0.1` tracks the current line. From 4ea9406a1d8c04fe42e7783ffd6101b817d7f505 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 23 Aug 2026 22:02:31 -0400 Subject: [PATCH 02/11] Add dataset contracts and the I18n rule family over generated ISO data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CountryCode, LanguageCode and CurrencyCode validate against the CountryDataset/LanguageDataset/CurrencyDataset contracts, bound singletonIf to bundled full-registry defaults so an application (or laranail/atlas) replaces the data without touching the rules. The data is generated, not typed: tools/build-datasets.php reads committed snapshots of the official registries (ISO 4217 List One as published 2026-01-01 by SIX, the Library of Congress ISO 639-2 registry, and an ISO 3166 pairing cross-checked between two agreeing org sources) and a suite test runs --check so the outputs cannot drift from their sources. Both local candidates for the currency list were stale in different ways — the local ICU still carried BYR/MRO/STD/VEF, the atlas snapshot HRK/SLL — which is exactly the failure mode the generated registry snapshot removes; retired codes now fail and the 2016-2024 replacements pass, pinned by test. --- CHANGELOG.md | 4 +- docs/tools/rule-library.md | 33 +- resources/data/currency-symbols.txt | 24 + resources/data/iso-3166-alpha2.txt | 252 +++ resources/data/iso-3166-alpha3.txt | 251 +++ resources/data/iso-4217.txt | 181 ++ resources/data/iso-639-1.txt | 185 ++ resources/lang/en/validation.php | 7 + src/Contracts/I18n/CountryDataset.php | 28 + src/Contracts/I18n/CurrencyDataset.php | 35 + src/Contracts/I18n/LanguageDataset.php | 22 + src/Rules/I18n/CountryCode.php | 55 + src/Rules/I18n/CurrencyCode.php | 76 + src/Rules/I18n/LanguageCode.php | 43 + src/Support/I18n/BundledCountryDataset.php | 38 + src/Support/I18n/BundledCurrencyDataset.php | 60 + src/Support/I18n/BundledLanguageDataset.php | 23 + src/Support/I18n/CodeFile.php | 81 + src/Support/RuleAliases.php | 10 + src/ValidationServiceProvider.php | 19 + tests/ConsoleCommandsTest.php | 2 +- tests/RuleRegistrarTest.php | 2 +- tests/Rules/I18n/I18nRulesTest.php | 160 ++ tools/build-datasets.php | 190 ++ tools/data-sources/iso-3166.txt | 255 +++ tools/data-sources/iso-4217-list-one.xml | 1956 +++++++++++++++++++ tools/data-sources/iso-639-2.txt | 487 +++++ 27 files changed, 4473 insertions(+), 6 deletions(-) create mode 100644 resources/data/currency-symbols.txt create mode 100644 resources/data/iso-3166-alpha2.txt create mode 100644 resources/data/iso-3166-alpha3.txt create mode 100644 resources/data/iso-4217.txt create mode 100644 resources/data/iso-639-1.txt create mode 100644 src/Contracts/I18n/CountryDataset.php create mode 100644 src/Contracts/I18n/CurrencyDataset.php create mode 100644 src/Contracts/I18n/LanguageDataset.php create mode 100644 src/Rules/I18n/CountryCode.php create mode 100644 src/Rules/I18n/CurrencyCode.php create mode 100644 src/Rules/I18n/LanguageCode.php create mode 100644 src/Support/I18n/BundledCountryDataset.php create mode 100644 src/Support/I18n/BundledCurrencyDataset.php create mode 100644 src/Support/I18n/BundledLanguageDataset.php create mode 100644 src/Support/I18n/CodeFile.php create mode 100644 tests/Rules/I18n/I18nRulesTest.php create mode 100644 tools/build-datasets.php create mode 100644 tools/data-sources/iso-3166.txt create mode 100644 tools/data-sources/iso-4217-list-one.xml create mode 100644 tools/data-sources/iso-639-2.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 377fd18..e6b32fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 54 rules across AntiSpam, Banking, Codes, Colour, - Crypto, Database, Email, Fiscal, Geo, Identifiers, Markup, Net, Network, Numbers, Postal, +- An extended rule library under `Rules\`: 57 rules across AntiSpam, Banking, Codes, Colour, + Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See [the reference](docs/tools/rule-library.md). diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 6428bda..c8266fb 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -254,8 +254,37 @@ the issuing authority. adds PR, GU, VI and the rest; `laranail_us_state:true`. - **`CaProvince`** — a Canadian province or territory, by code or full name. -Country, currency and language codes are **not** here — they live in `laranail/atlas`, which -owns that dataset. +Country, currency and language **codes** live in the I18n family below; `laranail/atlas` owns +the richer per-country dataset (names, regions, coordinates) and can replace the bundled code +sets by binding the same contracts. + +## I18n + +| Rule | Parameters | Alias | Message key | +|---|---|---|---| +| `CountryCode` | `bool $alpha3 = false, bool $caseInsensitive = false` | `country_code` | `country_code` | +| `CurrencyCode` | `bool $numeric = false, bool $symbol = false, bool $caseInsensitive = false` | `currency_code` | `currency_code` / `currency_code_numeric` / `currency_symbol` | +| `LanguageCode` | `bool $caseInsensitive = false` | `language_code` | `language_code` | + +- **`CountryCode`** — an assigned ISO 3166-1 code: alpha-2 (`KE`) by default, alpha-3 (`KEN`) + with `alpha3: true` (`laranail_country_code:true`). The bundled set is the full registry — + the 249 assigned alpha-2 codes plus the user-assigned `XK`, which real address data needs + and which deliberately has no alpha-3. +- **`CurrencyCode`** — a **current** ISO 4217 identifier: the alpha code (`USD`) by default, + the numeric code (`840`) with `numeric: true`, or a recognised symbol (`€`) with + `symbol: true`. One representation per instance — asking for two throws. Retired codes + (`HRK`, `SLL`, `ZWL`) fail; the bundled data is generated from the official registry and a + suite test pins it to its committed source. +- **`LanguageCode`** — an assigned ISO 639-1 code, lowercase canonical (`en`, `sw`). + +All three are strict about case by default because they usually guard a column an exact-match +lookup reads later; `caseInsensitive: true` folds before checking (it validates the folded +code — it does not rewrite the stored value). + +The code sets are container contracts (`Contracts\I18n\CountryDataset`, `CurrencyDataset`, +`LanguageDataset`) with bundled full-registry defaults bound `singletonIf`. Bind your own to +narrow (the locales an app actually ships) or extend (a ledger accepting historical currency +codes) without touching the rules. ## Identifiers diff --git a/resources/data/currency-symbols.txt b/resources/data/currency-symbols.txt new file mode 100644 index 0000000..5881ae8 --- /dev/null +++ b/resources/data/currency-symbols.txt @@ -0,0 +1,24 @@ +# CLDR currency symbols (en locale) for the ISO 4217 codes, one per +# line. GENERATED by tools/build-datasets.php from the local ICU — +# symbol renderings vary by ICU version, so this file is exempt from +# --check; regenerate deliberately and review the diff. +$ +A$ +CA$ +CFA +CFPF +CN¥ +EC$ +FCFA +HK$ +MX$ +NT$ +NZ$ +R$ +£ +¥ +₩ +₪ +₫ +€ +₹ diff --git a/resources/data/iso-3166-alpha2.txt b/resources/data/iso-3166-alpha2.txt new file mode 100644 index 0000000..88e35ba --- /dev/null +++ b/resources/data/iso-3166-alpha2.txt @@ -0,0 +1,252 @@ +# ISO 3166-1 alpha-2 country codes. GENERATED by tools/build-datasets.php +# from tools/data-sources/iso-3166.txt — do not edit by hand. +AD +AE +AF +AG +AI +AL +AM +AO +AQ +AR +AS +AT +AU +AW +AX +AZ +BA +BB +BD +BE +BF +BG +BH +BI +BJ +BL +BM +BN +BO +BQ +BR +BS +BT +BV +BW +BY +BZ +CA +CC +CD +CF +CG +CH +CI +CK +CL +CM +CN +CO +CR +CU +CV +CW +CX +CY +CZ +DE +DJ +DK +DM +DO +DZ +EC +EE +EG +EH +ER +ES +ET +FI +FJ +FK +FM +FO +FR +GA +GB +GD +GE +GF +GG +GH +GI +GL +GM +GN +GP +GQ +GR +GS +GT +GU +GW +GY +HK +HM +HN +HR +HT +HU +ID +IE +IL +IM +IN +IO +IQ +IR +IS +IT +JE +JM +JO +JP +KE +KG +KH +KI +KM +KN +KP +KR +KW +KY +KZ +LA +LB +LC +LI +LK +LR +LS +LT +LU +LV +LY +MA +MC +MD +ME +MF +MG +MH +MK +ML +MM +MN +MO +MP +MQ +MR +MS +MT +MU +MV +MW +MX +MY +MZ +NA +NC +NE +NF +NG +NI +NL +NO +NP +NR +NU +NZ +OM +PA +PE +PF +PG +PH +PK +PL +PM +PN +PR +PS +PT +PW +PY +QA +RE +RO +RS +RU +RW +SA +SB +SC +SD +SE +SG +SH +SI +SJ +SK +SL +SM +SN +SO +SR +SS +ST +SV +SX +SY +SZ +TC +TD +TF +TG +TH +TJ +TK +TL +TM +TN +TO +TR +TT +TV +TW +TZ +UA +UG +UM +US +UY +UZ +VA +VC +VE +VG +VI +VN +VU +WF +WS +XK +YE +YT +ZA +ZM +ZW diff --git a/resources/data/iso-3166-alpha3.txt b/resources/data/iso-3166-alpha3.txt new file mode 100644 index 0000000..38940c8 --- /dev/null +++ b/resources/data/iso-3166-alpha3.txt @@ -0,0 +1,251 @@ +# ISO 3166-1 alpha-3 country codes. GENERATED by tools/build-datasets.php +# from tools/data-sources/iso-3166.txt — do not edit by hand. +ABW +AFG +AGO +AIA +ALA +ALB +AND +ARE +ARG +ARM +ASM +ATA +ATF +ATG +AUS +AUT +AZE +BDI +BEL +BEN +BES +BFA +BGD +BGR +BHR +BHS +BIH +BLM +BLR +BLZ +BMU +BOL +BRA +BRB +BRN +BTN +BVT +BWA +CAF +CAN +CCK +CHE +CHL +CHN +CIV +CMR +COD +COG +COK +COL +COM +CPV +CRI +CUB +CUW +CXR +CYM +CYP +CZE +DEU +DJI +DMA +DNK +DOM +DZA +ECU +EGY +ERI +ESH +ESP +EST +ETH +FIN +FJI +FLK +FRA +FRO +FSM +GAB +GBR +GEO +GGY +GHA +GIB +GIN +GLP +GMB +GNB +GNQ +GRC +GRD +GRL +GTM +GUF +GUM +GUY +HKG +HMD +HND +HRV +HTI +HUN +IDN +IMN +IND +IOT +IRL +IRN +IRQ +ISL +ISR +ITA +JAM +JEY +JOR +JPN +KAZ +KEN +KGZ +KHM +KIR +KNA +KOR +KWT +LAO +LBN +LBR +LBY +LCA +LIE +LKA +LSO +LTU +LUX +LVA +MAC +MAF +MAR +MCO +MDA +MDG +MDV +MEX +MHL +MKD +MLI +MLT +MMR +MNE +MNG +MNP +MOZ +MRT +MSR +MTQ +MUS +MWI +MYS +MYT +NAM +NCL +NER +NFK +NGA +NIC +NIU +NLD +NOR +NPL +NRU +NZL +OMN +PAK +PAN +PCN +PER +PHL +PLW +PNG +POL +PRI +PRK +PRT +PRY +PSE +PYF +QAT +REU +ROU +RUS +RWA +SAU +SDN +SEN +SGP +SGS +SHN +SJM +SLB +SLE +SLV +SMR +SOM +SPM +SRB +SSD +STP +SUR +SVK +SVN +SWE +SWZ +SXM +SYC +SYR +TCA +TCD +TGO +THA +TJK +TKL +TKM +TLS +TON +TTO +TUN +TUR +TUV +TWN +TZA +UGA +UKR +UMI +URY +USA +UZB +VAT +VCT +VEN +VGB +VIR +VNM +VUT +WLF +WSM +YEM +ZAF +ZMB +ZWE diff --git a/resources/data/iso-4217.txt b/resources/data/iso-4217.txt new file mode 100644 index 0000000..2f9db8c --- /dev/null +++ b/resources/data/iso-4217.txt @@ -0,0 +1,181 @@ +# ISO 4217 currency codes (alpha, numeric). GENERATED by +# tools/build-datasets.php from the official List One published 2026-01-01 +# by SIX Financial Information — do not edit by hand. +AED 784 +AFN 971 +ALL 008 +AMD 051 +AOA 973 +ARS 032 +AUD 036 +AWG 533 +AZN 944 +BAM 977 +BBD 052 +BDT 050 +BHD 048 +BIF 108 +BMD 060 +BND 096 +BOB 068 +BOV 984 +BRL 986 +BSD 044 +BTN 064 +BWP 072 +BYN 933 +BZD 084 +CAD 124 +CDF 976 +CHE 947 +CHF 756 +CHW 948 +CLF 990 +CLP 152 +CNY 156 +COP 170 +COU 970 +CRC 188 +CUP 192 +CVE 132 +CZK 203 +DJF 262 +DKK 208 +DOP 214 +DZD 012 +EGP 818 +ERN 232 +ETB 230 +EUR 978 +FJD 242 +FKP 238 +GBP 826 +GEL 981 +GHS 936 +GIP 292 +GMD 270 +GNF 324 +GTQ 320 +GYD 328 +HKD 344 +HNL 340 +HTG 332 +HUF 348 +IDR 360 +ILS 376 +INR 356 +IQD 368 +IRR 364 +ISK 352 +JMD 388 +JOD 400 +JPY 392 +KES 404 +KGS 417 +KHR 116 +KMF 174 +KPW 408 +KRW 410 +KWD 414 +KYD 136 +KZT 398 +LAK 418 +LBP 422 +LKR 144 +LRD 430 +LSL 426 +LYD 434 +MAD 504 +MDL 498 +MGA 969 +MKD 807 +MMK 104 +MNT 496 +MOP 446 +MRU 929 +MUR 480 +MVR 462 +MWK 454 +MXN 484 +MXV 979 +MYR 458 +MZN 943 +NAD 516 +NGN 566 +NIO 558 +NOK 578 +NPR 524 +NZD 554 +OMR 512 +PAB 590 +PEN 604 +PGK 598 +PHP 608 +PKR 586 +PLN 985 +PYG 600 +QAR 634 +RON 946 +RSD 941 +RUB 643 +RWF 646 +SAR 682 +SBD 090 +SCR 690 +SDG 938 +SEK 752 +SGD 702 +SHP 654 +SLE 925 +SOS 706 +SRD 968 +SSP 728 +STN 930 +SVC 222 +SYP 760 +SZL 748 +THB 764 +TJS 972 +TMT 934 +TND 788 +TOP 776 +TRY 949 +TTD 780 +TWD 901 +TZS 834 +UAH 980 +UGX 800 +USD 840 +USN 997 +UYI 940 +UYU 858 +UYW 927 +UZS 860 +VED 926 +VES 928 +VND 704 +VUV 548 +WST 882 +XAD 396 +XAF 950 +XAG 961 +XAU 959 +XBA 955 +XBB 956 +XBC 957 +XBD 958 +XCD 951 +XCG 532 +XDR 960 +XOF 952 +XPD 964 +XPF 953 +XPT 962 +XSU 994 +XTS 963 +XUA 965 +XXX 999 +YER 886 +ZAR 710 +ZMW 967 +ZWG 924 diff --git a/resources/data/iso-639-1.txt b/resources/data/iso-639-1.txt new file mode 100644 index 0000000..0a17ef1 --- /dev/null +++ b/resources/data/iso-639-1.txt @@ -0,0 +1,185 @@ +# ISO 639-1 language codes. GENERATED by tools/build-datasets.php from +# the Library of Congress ISO 639-2 registry — do not edit by hand. +aa +ab +ae +af +ak +am +an +ar +as +av +ay +az +ba +be +bg +bi +bm +bn +bo +br +bs +ca +ce +ch +co +cr +cs +cu +cv +cy +da +de +dv +dz +ee +el +en +eo +es +et +eu +fa +ff +fi +fj +fo +fr +fy +ga +gd +gl +gn +gu +gv +ha +he +hi +ho +hr +ht +hu +hy +hz +ia +id +ie +ig +ii +ik +io +is +it +iu +ja +jv +ka +kg +ki +kj +kk +kl +km +kn +ko +kr +ks +ku +kv +kw +ky +la +lb +lg +li +ln +lo +lt +lu +lv +mg +mh +mi +mk +ml +mn +mr +ms +mt +my +na +nb +nd +ne +ng +nl +nn +no +nr +nv +ny +oc +oj +om +or +os +pa +pi +pl +ps +pt +qu +rm +rn +ro +ru +rw +sa +sc +sd +se +sg +si +sk +sl +sm +sn +so +sq +sr +ss +st +su +sv +sw +ta +te +tg +th +ti +tk +tl +tn +to +tr +ts +tt +tw +ty +ug +uk +ur +uz +ve +vi +vo +wa +wo +xh +yi +yo +za +zh +zu diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index fe77145..15cd27c 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -101,6 +101,13 @@ 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', + // I18n + 'country_code' => 'The :attribute must be a valid ISO 3166-1 country code.', + 'currency_code' => 'The :attribute must be a valid ISO 4217 currency code.', + 'currency_code_numeric' => 'The :attribute must be a valid ISO 4217 numeric currency code.', + 'currency_symbol' => 'The :attribute must be a recognised currency symbol.', + 'language_code' => 'The :attribute must be a valid ISO 639-1 language code.', + // Geo 'ca_province' => 'The :attribute must be a Canadian province or territory.', 'lat_lng' => 'The :attribute must be a latitude,longitude pair.', diff --git a/src/Contracts/I18n/CountryDataset.php b/src/Contracts/I18n/CountryDataset.php new file mode 100644 index 0000000..99987b4 --- /dev/null +++ b/src/Contracts/I18n/CountryDataset.php @@ -0,0 +1,28 @@ +translate(); + + return; + } + + $code = $this->caseInsensitive ? strtoupper($value) : $value; + + $assigned = $this->alpha3 + ? $this->dataset()->isAlpha3($code) + : $this->dataset()->isAlpha2($code); + + if (! $assigned) { + $fail('laranail-validation::validation.country_code')->translate(); + } + } + + private function dataset(): CountryDataset + { + return $this->dataset ??= resolve(CountryDataset::class); + } +} diff --git a/src/Rules/I18n/CurrencyCode.php b/src/Rules/I18n/CurrencyCode.php new file mode 100644 index 0000000..486750a --- /dev/null +++ b/src/Rules/I18n/CurrencyCode.php @@ -0,0 +1,76 @@ +numeric && $this->symbol) { + throw new LogicException( + 'CurrencyCode validates one representation per instance — pick numeric: or symbol:, not both.', + ); + } + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value)) { + $this->fail($fail); + + return; + } + + $passes = match (true) { + $this->numeric => $this->dataset()->isNumericCode($value), + $this->symbol => $this->dataset()->isSymbol($value), + default => $this->dataset()->isCode($this->caseInsensitive ? strtoupper($value) : $value), + }; + + if (! $passes) { + $this->fail($fail); + } + } + + /** @param Closure(string): PotentiallyTranslatedString $fail */ + private function fail(Closure $fail): void + { + $key = match (true) { + $this->numeric => 'laranail-validation::validation.currency_code_numeric', + $this->symbol => 'laranail-validation::validation.currency_symbol', + default => 'laranail-validation::validation.currency_code', + }; + + $fail($key)->translate(); + } + + private function dataset(): CurrencyDataset + { + return $this->dataset ??= resolve(CurrencyDataset::class); + } +} diff --git a/src/Rules/I18n/LanguageCode.php b/src/Rules/I18n/LanguageCode.php new file mode 100644 index 0000000..cb11ef5 --- /dev/null +++ b/src/Rules/I18n/LanguageCode.php @@ -0,0 +1,43 @@ +translate(); + + return; + } + + $code = $this->caseInsensitive ? strtolower($value) : $value; + + if (! $this->dataset()->isAlpha2($code)) { + $fail('laranail-validation::validation.language_code')->translate(); + } + } + + private function dataset(): LanguageDataset + { + return $this->dataset ??= resolve(LanguageDataset::class); + } +} diff --git a/src/Support/I18n/BundledCountryDataset.php b/src/Support/I18n/BundledCountryDataset.php new file mode 100644 index 0000000..482909b --- /dev/null +++ b/src/Support/I18n/BundledCountryDataset.php @@ -0,0 +1,38 @@ +|null */ + private ?array $alpha2 = null; + + /** @var array|null */ + private ?array $alpha3 = null; + + public function isAlpha2(string $code): bool + { + $this->alpha2 ??= CodeFile::load(__DIR__ . '/../../../resources/data/iso-3166-alpha2.txt'); + + return isset($this->alpha2[$code]); + } + + public function isAlpha3(string $code): bool + { + $this->alpha3 ??= CodeFile::load(__DIR__ . '/../../../resources/data/iso-3166-alpha3.txt'); + + return isset($this->alpha3[$code]); + } +} diff --git a/src/Support/I18n/BundledCurrencyDataset.php b/src/Support/I18n/BundledCurrencyDataset.php new file mode 100644 index 0000000..59604c7 --- /dev/null +++ b/src/Support/I18n/BundledCurrencyDataset.php @@ -0,0 +1,60 @@ +|null alpha code => numeric code */ + private ?array $codes = null; + + /** @var array|null */ + private ?array $numeric = null; + + /** @var array|null */ + private ?array $symbols = null; + + public function isCode(string $code): bool + { + return isset($this->codes()[$code]); + } + + public function isNumericCode(string $code): bool + { + if ($this->numeric === null) { + $this->numeric = []; + + foreach ($this->codes() as $numeric) { + $this->numeric[$numeric] = true; + } + } + + return isset($this->numeric[$code]); + } + + public function isSymbol(string $symbol): bool + { + $this->symbols ??= CodeFile::load(__DIR__ . '/../../../resources/data/currency-symbols.txt'); + + return isset($this->symbols[$symbol]); + } + + /** @return array */ + private function codes(): array + { + return $this->codes ??= CodeFile::loadMap(__DIR__ . '/../../../resources/data/iso-4217.txt'); + } +} diff --git a/src/Support/I18n/BundledLanguageDataset.php b/src/Support/I18n/BundledLanguageDataset.php new file mode 100644 index 0000000..ee43593 --- /dev/null +++ b/src/Support/I18n/BundledLanguageDataset.php @@ -0,0 +1,23 @@ +|null */ + private ?array $alpha2 = null; + + public function isAlpha2(string $code): bool + { + $this->alpha2 ??= CodeFile::load(__DIR__ . '/../../../resources/data/iso-639-1.txt'); + + return isset($this->alpha2[$code]); + } +} diff --git a/src/Support/I18n/CodeFile.php b/src/Support/I18n/CodeFile.php new file mode 100644 index 0000000..8c583f7 --- /dev/null +++ b/src/Support/I18n/CodeFile.php @@ -0,0 +1,81 @@ + + */ + public static function load(string $path): array + { + $entries = []; + + foreach (self::lines($path) as $line) { + $entries[$line] = true; + } + + return $entries; + } + + /** + * A two-column map: `KEY VALUE` per line; a `-` value means "none" and + * the row is kept key-only (value dropped). + * + * @return array + */ + public static function loadMap(string $path): array + { + $entries = []; + + foreach (self::lines($path) as $line) { + [$key, $value] = explode(' ', $line, 2); + + if ($value !== '-') { + $entries[$key] = $value; + } + } + + return $entries; + } + + /** @return list */ + private static function lines(string $path): array + { + $contents = @file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read the bundled data file at [{$path}]."); + } + + $lines = []; + + foreach (explode("\n", $contents) as $line) { + $line = trim($line); + + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + $lines[] = $line; + } + + return $lines; + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index e31de18..84ea419 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -33,6 +33,9 @@ use Simtabi\Laranail\Validation\Rules\Geo\LatLng; use Simtabi\Laranail\Validation\Rules\Geo\Longitude; use Simtabi\Laranail\Validation\Rules\Geo\UsState; +use Simtabi\Laranail\Validation\Rules\I18n\CountryCode; +use Simtabi\Laranail\Validation\Rules\I18n\CurrencyCode; +use Simtabi\Laranail\Validation\Rules\I18n\LanguageCode; use Simtabi\Laranail\Validation\Rules\Identifiers\Imei; use Simtabi\Laranail\Validation\Rules\Identifiers\Jwt; use Simtabi\Laranail\Validation\Rules\Identifiers\SemVer; @@ -154,6 +157,13 @@ public static function map(): array // to read the country from a sibling field. 'postal_code' => self::postalCode(...), + // I18n — positional flags mirror the constructors: + // `laranail_country_code:true` is alpha-3 mode, + // `laranail_currency_code:true` numeric, `:false,true` symbol. + 'country_code' => static fn (array $p): ValidationRule => new CountryCode(...self::flags($p)), + 'currency_code' => static fn (array $p): ValidationRule => new CurrencyCode(...self::flags($p)), + 'language_code' => static fn (array $p): ValidationRule => new LanguageCode(...self::flags($p)), + // Net 'cidr' => static fn (): ValidationRule => new Cidr(), 'subdomain' => static fn (): ValidationRule => new Subdomain(), diff --git a/src/ValidationServiceProvider.php b/src/ValidationServiceProvider.php index ffe4702..82fabd0 100644 --- a/src/ValidationServiceProvider.php +++ b/src/ValidationServiceProvider.php @@ -15,8 +15,14 @@ use Simtabi\Laranail\Validation\Contracts\Email\DisposableDomainList; use Simtabi\Laranail\Validation\Contracts\Email\DnsResolver; use Simtabi\Laranail\Validation\Contracts\Email\RoleAccountList; +use Simtabi\Laranail\Validation\Contracts\I18n\CountryDataset; +use Simtabi\Laranail\Validation\Contracts\I18n\CurrencyDataset; +use Simtabi\Laranail\Validation\Contracts\I18n\LanguageDataset; use Simtabi\Laranail\Validation\Support\Email\BundledDisposableDomainList; use Simtabi\Laranail\Validation\Support\Email\BundledRoleAccountList; +use Simtabi\Laranail\Validation\Support\I18n\BundledCountryDataset; +use Simtabi\Laranail\Validation\Support\I18n\BundledCurrencyDataset; +use Simtabi\Laranail\Validation\Support\I18n\BundledLanguageDataset; use Simtabi\Laranail\Validation\Support\RuleAliases; use Simtabi\Laranail\Validation\Support\RuleRegistrar; use Stringable; @@ -61,6 +67,7 @@ public function registeringPackage(): void $this->mergeConfigFrom($this->configPath(), 'laranail.validation'); $this->bindEmailListFallbacks(); + $this->bindI18nDatasetFallbacks(); // One registry for the whole application: the alias wiring, the // console command and the docs tooling all read this singleton, and @@ -86,6 +93,18 @@ private function bindEmailListFallbacks(): void $this->app->singletonIf(DnsResolver::class, CachedDnsResolver::class); } + /** + * Bind the bundled ISO datasets, with the same `singletonIf` asymmetry as + * the email lists: an application (or laranail/atlas) that binds its own + * dataset unconditionally wins regardless of provider order. + */ + private function bindI18nDatasetFallbacks(): void + { + $this->app->singletonIf(CountryDataset::class, BundledCountryDataset::class); + $this->app->singletonIf(CurrencyDataset::class, BundledCurrencyDataset::class); + $this->app->singletonIf(LanguageDataset::class, BundledLanguageDataset::class); + } + public function bootingPackage(): void { $this->applyBatchLimit(); diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 5eff336..9f8c73d 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('54 rules') + ->expectsOutputToContain('57 rules') ->assertSuccessful(); }); diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index f285f32..e188801 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(54); + ->and(count($classes))->toBe(57); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/I18n/I18nRulesTest.php b/tests/Rules/I18n/I18nRulesTest.php new file mode 100644 index 0000000..9270233 --- /dev/null +++ b/tests/Rules/I18n/I18nRulesTest.php @@ -0,0 +1,160 @@ +toBeTrue(); +})->with(['US', 'KE', 'DE', 'XK']); + +it('rejects unassigned, lowercase and alpha-3 values in alpha-2 mode', function (mixed $value): void { + // Empty strings are absent from this list on purpose: Laravel skips + // non-implicit rules on '' — presence is `required`'s job, not this rule's. + expect(ruleAccepts(new CountryCode(), $value))->toBeFalse(); +})->with(['AA', 'us', 'USA', 'U', 12, null, [['US']]]); + +it('accepts assigned alpha-3 codes only in alpha-3 mode', function (): void { + expect(ruleAccepts(new CountryCode(alpha3: true), 'KEN'))->toBeTrue() + ->and(ruleAccepts(new CountryCode(alpha3: true), 'KE'))->toBeFalse() + ->and(ruleAccepts(new CountryCode(alpha3: true), 'XXX'))->toBeFalse(); +}); + +it('folds case only when asked to', function (): void { + expect(ruleAccepts(new CountryCode(caseInsensitive: true), 'us'))->toBeTrue() + ->and(ruleAccepts(new CountryCode(alpha3: true, caseInsensitive: true), 'ken'))->toBeTrue(); +}); + +it('has no alpha-3 for the user-assigned XK', function (): void { + // XK (Kosovo) is user-assigned, kept in alpha-2 because real address data + // needs it; ISO assigns it no alpha-3, so none is accepted. + expect(ruleAccepts(new CountryCode(alpha3: true), 'UNK'))->toBeFalse() + ->and(ruleAccepts(new CountryCode(alpha3: true), 'XKX'))->toBeFalse(); +}); + +it('honours a bound CountryDataset', function (): void { + $this->app->instance(CountryDataset::class, new class implements CountryDataset { + public function isAlpha2(string $code): bool + { + return $code === 'ZZ'; + } + + public function isAlpha3(string $code): bool + { + return false; + } + }); + + expect(ruleAccepts(new CountryCode(), 'ZZ'))->toBeTrue() + ->and(ruleAccepts(new CountryCode(), 'US'))->toBeFalse(); +}); + +// ========================================================================= +// LanguageCode — ISO 639-1, lowercase canonical +// ========================================================================= + +it('accepts assigned ISO 639-1 codes', function (string $value): void { + expect(ruleAccepts(new LanguageCode(), $value))->toBeTrue(); +})->with(['en', 'sw', 'de', 'zh']); + +it('rejects unassigned, uppercase and three-letter values', function (mixed $value): void { + expect(ruleAccepts(new LanguageCode(), $value))->toBeFalse(); +})->with(['EN', 'eng', 'xx', 3, null]); + +it('folds language case only when asked to', function (): void { + expect(ruleAccepts(new LanguageCode(caseInsensitive: true), 'EN'))->toBeTrue(); +}); + +it('honours a bound LanguageDataset', function (): void { + $this->app->instance(LanguageDataset::class, new class implements LanguageDataset { + public function isAlpha2(string $code): bool + { + return $code === 'zz'; + } + }); + + expect(ruleAccepts(new LanguageCode(), 'zz'))->toBeTrue() + ->and(ruleAccepts(new LanguageCode(), 'en'))->toBeFalse(); +}); + +// ========================================================================= +// CurrencyCode — ISO 4217 alpha / numeric / symbol +// ========================================================================= + +it('accepts current alpha currency codes, including the 2016-2024 replacements', function (string $value): void { + expect(ruleAccepts(new CurrencyCode(), $value))->toBeTrue(); +})->with(['USD', 'EUR', 'KES', 'BYN', 'MRU', 'STN', 'VES', 'SLE', 'ZWG', 'XAU', 'XXX']); + +it('rejects retired and unassigned alpha codes', function (mixed $value): void { + // HRK (Croatia -> euro, 2023), SLL (redenominated, withdrawn 2023) and + // ZWL (replaced by ZWG, 2024) are exactly what a stale list would accept. + expect(ruleAccepts(new CurrencyCode(), $value))->toBeFalse(); +})->with(['HRK', 'SLL', 'ZWL', 'usd', 'ABC', '840', 840, null]); + +it('validates numeric codes in numeric mode', function (): void { + expect(ruleAccepts(new CurrencyCode(numeric: true), '840'))->toBeTrue() + ->and(ruleAccepts(new CurrencyCode(numeric: true), '997'))->toBeTrue() // USN, a funds code + ->and(ruleAccepts(new CurrencyCode(numeric: true), 'USD'))->toBeFalse() + ->and(ruleAccepts(new CurrencyCode(numeric: true), '000'))->toBeFalse(); +}); + +it('validates symbols in symbol mode', function (): void { + expect(ruleAccepts(new CurrencyCode(symbol: true), '€'))->toBeTrue() + ->and(ruleAccepts(new CurrencyCode(symbol: true), '£'))->toBeTrue() + ->and(ruleAccepts(new CurrencyCode(symbol: true), 'EUR'))->toBeFalse(); +}); + +it('refuses numeric and symbol together', function (): void { + new CurrencyCode(numeric: true, symbol: true); +})->throws(LogicException::class); + +it('folds currency case only when asked to', function (): void { + expect(ruleAccepts(new CurrencyCode(caseInsensitive: true), 'usd'))->toBeTrue(); +}); + +it('honours a bound CurrencyDataset', function (): void { + $this->app->instance(CurrencyDataset::class, new class implements CurrencyDataset { + public function isCode(string $code): bool + { + return $code === 'ZZZ'; + } + + public function isNumericCode(string $code): bool + { + return false; + } + + public function isSymbol(string $symbol): bool + { + return false; + } + }); + + expect(ruleAccepts(new CurrencyCode(), 'ZZZ'))->toBeTrue() + ->and(ruleAccepts(new CurrencyCode(), 'USD'))->toBeFalse(); +}); + +// ========================================================================= +// The bundled datasets — counts pinned to the generated files +// ========================================================================= + +it('keeps the generated datasets in step with their committed sources', function (): void { + $php = (string) (new PhpExecutableFinder())->find(); + expect($php)->not->toBe(''); + + $process = new Process( + [$php, dirname(__DIR__, 3) . '/tools/build-datasets.php', '--check'], + ); + $process->run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); +})->skip(! class_exists(Process::class), 'symfony/process not installed'); diff --git a/tools/build-datasets.php b/tools/build-datasets.php new file mode 100644 index 0000000..1f54c38 --- /dev/null +++ b/tools/build-datasets.php @@ -0,0 +1,190 @@ + content pairs, checkable ones only */ +function buildCheckable(string $sources): array +{ + // --- ISO 3166 ----------------------------------------------------------- + $alpha2 = []; + $alpha3 = []; + + foreach (explode("\n", (string) file_get_contents($sources . '/iso-3166.txt')) as $line) { + $line = trim($line); + + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + [$a2, $a3] = explode(' ', $line); + $alpha2[] = $a2; + + if ($a3 !== '-') { + $alpha3[] = $a3; + } + } + + sort($alpha2); + sort($alpha3); + + // --- ISO 4217 ----------------------------------------------------------- + $xml = new SimpleXMLElement((string) file_get_contents($sources . '/iso-4217-list-one.xml')); + $published = (string) $xml['Pblshd']; + $currencies = []; + + foreach ($xml->CcyTbl->CcyNtry as $entry) { + // Territories with "no universal currency" carry no Ccy element. + if (! isset($entry->Ccy)) { + continue; + } + + $code = (string) $entry->Ccy; + $numeric = isset($entry->CcyNbr) ? (string) $entry->CcyNbr : '-'; + $currencies[$code] = $numeric; + } + + ksort($currencies); + + // --- ISO 639-1 ---------------------------------------------------------- + $languages = []; + $registry = (string) file_get_contents($sources . '/iso-639-2.txt'); + $registry = preg_replace('/^\xEF\xBB\xBF/', '', $registry) ?? $registry; + + foreach (explode("\n", $registry) as $line) { + $columns = explode('|', trim($line)); + + if (count($columns) >= 3 && $columns[2] !== '') { + $languages[$columns[2]] = true; + } + } + + $languages = array_keys($languages); + sort($languages); + + // --- Render ------------------------------------------------------------- + $files = []; + + $files['iso-3166-alpha2.txt'] = "# ISO 3166-1 alpha-2 country codes. GENERATED by tools/build-datasets.php\n" + . "# from tools/data-sources/iso-3166.txt — do not edit by hand.\n" + . implode("\n", $alpha2) . "\n"; + + $files['iso-3166-alpha3.txt'] = "# ISO 3166-1 alpha-3 country codes. GENERATED by tools/build-datasets.php\n" + . "# from tools/data-sources/iso-3166.txt — do not edit by hand.\n" + . implode("\n", $alpha3) . "\n"; + + $lines = []; + + foreach ($currencies as $code => $numeric) { + $lines[] = $code . ' ' . $numeric; + } + + $files['iso-4217.txt'] = "# ISO 4217 currency codes (alpha, numeric). GENERATED by\n" + . "# tools/build-datasets.php from the official List One published {$published}\n" + . "# by SIX Financial Information — do not edit by hand.\n" + . implode("\n", $lines) . "\n"; + + $files['iso-639-1.txt'] = "# ISO 639-1 language codes. GENERATED by tools/build-datasets.php from\n" + . "# the Library of Congress ISO 639-2 registry — do not edit by hand.\n" + . implode("\n", $languages) . "\n"; + + return $files; +} + +/** Distinct CLDR currency symbols (en locale) that differ from their code. */ +function buildSymbols(string $sources): string +{ + $xml = new SimpleXMLElement((string) file_get_contents($sources . '/iso-4217-list-one.xml')); + $codes = []; + + foreach ($xml->CcyTbl->CcyNtry as $entry) { + if (isset($entry->Ccy)) { + $codes[(string) $entry->Ccy] = true; + } + } + + $bundle = ResourceBundle::create('en', 'ICUDATA-curr', false); + $table = $bundle?->get('Currencies'); + $symbols = []; + + foreach (array_keys($codes) as $code) { + $row = $table?->get($code); + $symbol = $row?->get(0); + + if (is_string($symbol) && $symbol !== $code) { + $symbols[$symbol] = true; + } + } + + $symbols = array_keys($symbols); + sort($symbols); + + return "# CLDR currency symbols (en locale) for the ISO 4217 codes, one per\n" + . "# line. GENERATED by tools/build-datasets.php from the local ICU —\n" + . "# symbol renderings vary by ICU version, so this file is exempt from\n" + . "# --check; regenerate deliberately and review the diff.\n" + . implode("\n", $symbols) . "\n"; +} + +$files = buildCheckable($sources); + +if ($check) { + $stale = []; + + foreach ($files as $name => $content) { + if ((string) @file_get_contents($out . '/' . $name) !== $content) { + $stale[] = $name; + } + } + + if ($stale !== []) { + fwrite(STDERR, 'Stale datasets (regenerate with `php tools/build-datasets.php`): ' + . implode(', ', $stale) . "\n"); + exit(1); + } + + echo "Datasets match their sources.\n"; + exit(0); +} + +$files['currency-symbols.txt'] = buildSymbols($sources); + +foreach ($files as $name => $content) { + file_put_contents($out . '/' . $name, $content); + echo "wrote resources/data/{$name}\n"; +} diff --git a/tools/data-sources/iso-3166.txt b/tools/data-sources/iso-3166.txt new file mode 100644 index 0000000..daadfb2 --- /dev/null +++ b/tools/data-sources/iso-3166.txt @@ -0,0 +1,255 @@ +# ISO 3166-1 country codes: alpha-2, alpha-3 — one pair per line. +# Derived 2026-08-23 from two agreeing Simtabi sources: the laranail/atlas +# generated dataset (rinvex-derived, CI-gated) and the enekia archive's +# ISO 3166 registry. XK (Kosovo) is user-assigned, not ISO — kept because +# real-world address data requires it; it has no assigned alpha-3 ('-'). +AD AND +AE ARE +AF AFG +AG ATG +AI AIA +AL ALB +AM ARM +AO AGO +AQ ATA +AR ARG +AS ASM +AT AUT +AU AUS +AW ABW +AX ALA +AZ AZE +BA BIH +BB BRB +BD BGD +BE BEL +BF BFA +BG BGR +BH BHR +BI BDI +BJ BEN +BL BLM +BM BMU +BN BRN +BO BOL +BQ BES +BR BRA +BS BHS +BT BTN +BV BVT +BW BWA +BY BLR +BZ BLZ +CA CAN +CC CCK +CD COD +CF CAF +CG COG +CH CHE +CI CIV +CK COK +CL CHL +CM CMR +CN CHN +CO COL +CR CRI +CU CUB +CV CPV +CW CUW +CX CXR +CY CYP +CZ CZE +DE DEU +DJ DJI +DK DNK +DM DMA +DO DOM +DZ DZA +EC ECU +EE EST +EG EGY +EH ESH +ER ERI +ES ESP +ET ETH +FI FIN +FJ FJI +FK FLK +FM FSM +FO FRO +FR FRA +GA GAB +GB GBR +GD GRD +GE GEO +GF GUF +GG GGY +GH GHA +GI GIB +GL GRL +GM GMB +GN GIN +GP GLP +GQ GNQ +GR GRC +GS SGS +GT GTM +GU GUM +GW GNB +GY GUY +HK HKG +HM HMD +HN HND +HR HRV +HT HTI +HU HUN +ID IDN +IE IRL +IL ISR +IM IMN +IN IND +IO IOT +IQ IRQ +IR IRN +IS ISL +IT ITA +JE JEY +JM JAM +JO JOR +JP JPN +KE KEN +KG KGZ +KH KHM +KI KIR +KM COM +KN KNA +KP PRK +KR KOR +KW KWT +KY CYM +KZ KAZ +LA LAO +LB LBN +LC LCA +LI LIE +LK LKA +LR LBR +LS LSO +LT LTU +LU LUX +LV LVA +LY LBY +MA MAR +MC MCO +MD MDA +ME MNE +MF MAF +MG MDG +MH MHL +MK MKD +ML MLI +MM MMR +MN MNG +MO MAC +MP MNP +MQ MTQ +MR MRT +MS MSR +MT MLT +MU MUS +MV MDV +MW MWI +MX MEX +MY MYS +MZ MOZ +NA NAM +NC NCL +NE NER +NF NFK +NG NGA +NI NIC +NL NLD +NO NOR +NP NPL +NR NRU +NU NIU +NZ NZL +OM OMN +PA PAN +PE PER +PF PYF +PG PNG +PH PHL +PK PAK +PL POL +PM SPM +PN PCN +PR PRI +PS PSE +PT PRT +PW PLW +PY PRY +QA QAT +RE REU +RO ROU +RS SRB +RU RUS +RW RWA +SA SAU +SB SLB +SC SYC +SD SDN +SE SWE +SG SGP +SH SHN +SI SVN +SJ SJM +SK SVK +SL SLE +SM SMR +SN SEN +SO SOM +SR SUR +SS SSD +ST STP +SV SLV +SX SXM +SY SYR +SZ SWZ +TC TCA +TD TCD +TF ATF +TG TGO +TH THA +TJ TJK +TK TKL +TL TLS +TM TKM +TN TUN +TO TON +TR TUR +TT TTO +TV TUV +TW TWN +TZ TZA +UA UKR +UG UGA +UM UMI +US USA +UY URY +UZ UZB +VA VAT +VC VCT +VE VEN +VG VGB +VI VIR +VN VNM +VU VUT +WF WLF +WS WSM +XK - +YE YEM +YT MYT +ZA ZAF +ZM ZMB +ZW ZWE diff --git a/tools/data-sources/iso-4217-list-one.xml b/tools/data-sources/iso-4217-list-one.xml new file mode 100644 index 0000000..9342222 --- /dev/null +++ b/tools/data-sources/iso-4217-list-one.xml @@ -0,0 +1,1956 @@ + + + + + AFGHANISTAN + Afghani + AFN + 971 + 2 + + + ÅLAND ISLANDS + Euro + EUR + 978 + 2 + + + ALBANIA + Lek + ALL + 008 + 2 + + + ALGERIA + Algerian Dinar + DZD + 012 + 2 + + + AMERICAN SAMOA + US Dollar + USD + 840 + 2 + + + ANDORRA + Euro + EUR + 978 + 2 + + + ANGOLA + Kwanza + AOA + 973 + 2 + + + ANGUILLA + East Caribbean Dollar + XCD + 951 + 2 + + + ANTARCTICA + No universal currency + + + ANTIGUA AND BARBUDA + East Caribbean Dollar + XCD + 951 + 2 + + + ARAB MONETARY FUND + Arab Accounting Dinar + XAD + 396 + 2 + + + ARGENTINA + Argentine Peso + ARS + 032 + 2 + + + ARMENIA + Armenian Dram + AMD + 051 + 2 + + + ARUBA + Aruban Florin + AWG + 533 + 2 + + + AUSTRALIA + Australian Dollar + AUD + 036 + 2 + + + AUSTRIA + Euro + EUR + 978 + 2 + + + AZERBAIJAN + Azerbaijan Manat + AZN + 944 + 2 + + + BAHAMAS (THE) + Bahamian Dollar + BSD + 044 + 2 + + + BAHRAIN + Bahraini Dinar + BHD + 048 + 3 + + + BANGLADESH + Taka + BDT + 050 + 2 + + + BARBADOS + Barbados Dollar + BBD + 052 + 2 + + + BELARUS + Belarusian Ruble + BYN + 933 + 2 + + + BELGIUM + Euro + EUR + 978 + 2 + + + BELIZE + Belize Dollar + BZD + 084 + 2 + + + BENIN + CFA Franc BCEAO + XOF + 952 + 0 + + + BERMUDA + Bermudian Dollar + BMD + 060 + 2 + + + BHUTAN + Indian Rupee + INR + 356 + 2 + + + BHUTAN + Ngultrum + BTN + 064 + 2 + + + BOLIVIA (PLURINATIONAL STATE OF) + Boliviano + BOB + 068 + 2 + + + BOLIVIA (PLURINATIONAL STATE OF) + Mvdol + BOV + 984 + 2 + + + BONAIRE, SINT EUSTATIUS AND SABA + US Dollar + USD + 840 + 2 + + + BOSNIA AND HERZEGOVINA + Convertible Mark + BAM + 977 + 2 + + + BOTSWANA + Pula + BWP + 072 + 2 + + + BOUVET ISLAND + Norwegian Krone + NOK + 578 + 2 + + + BRAZIL + Brazilian Real + BRL + 986 + 2 + + + BRITISH INDIAN OCEAN TERRITORY (THE) + US Dollar + USD + 840 + 2 + + + BRUNEI DARUSSALAM + Brunei Dollar + BND + 096 + 2 + + + BULGARIA + Euro + EUR + 978 + 2 + + + BURKINA FASO + CFA Franc BCEAO + XOF + 952 + 0 + + + BURUNDI + Burundi Franc + BIF + 108 + 0 + + + CABO VERDE + Cabo Verde Escudo + CVE + 132 + 2 + + + CAMBODIA + Riel + KHR + 116 + 2 + + + CAMEROON + CFA Franc BEAC + XAF + 950 + 0 + + + CANADA + Canadian Dollar + CAD + 124 + 2 + + + CAYMAN ISLANDS (THE) + Cayman Islands Dollar + KYD + 136 + 2 + + + CENTRAL AFRICAN REPUBLIC (THE) + CFA Franc BEAC + XAF + 950 + 0 + + + CHAD + CFA Franc BEAC + XAF + 950 + 0 + + + CHILE + Chilean Peso + CLP + 152 + 0 + + + CHILE + Unidad de Fomento + CLF + 990 + 4 + + + CHINA + Yuan Renminbi + CNY + 156 + 2 + + + CHRISTMAS ISLAND + Australian Dollar + AUD + 036 + 2 + + + COCOS (KEELING) ISLANDS (THE) + Australian Dollar + AUD + 036 + 2 + + + COLOMBIA + Colombian Peso + COP + 170 + 2 + + + COLOMBIA + Unidad de Valor Real + COU + 970 + 2 + + + COMOROS (THE) + Comorian Franc + KMF + 174 + 0 + + + CONGO (THE DEMOCRATIC REPUBLIC OF THE) + Congolese Franc + CDF + 976 + 2 + + + CONGO (THE) + CFA Franc BEAC + XAF + 950 + 0 + + + COOK ISLANDS (THE) + New Zealand Dollar + NZD + 554 + 2 + + + COSTA RICA + Costa Rican Colon + CRC + 188 + 2 + + + CÔTE D'IVOIRE + CFA Franc BCEAO + XOF + 952 + 0 + + + CROATIA + Euro + EUR + 978 + 2 + + + CUBA + Cuban Peso + CUP + 192 + 2 + + + CURAÇAO + Caribbean Guilder + XCG + 532 + 2 + + + CYPRUS + Euro + EUR + 978 + 2 + + + CZECHIA + Czech Koruna + CZK + 203 + 2 + + + DENMARK + Danish Krone + DKK + 208 + 2 + + + DJIBOUTI + Djibouti Franc + DJF + 262 + 0 + + + DOMINICA + East Caribbean Dollar + XCD + 951 + 2 + + + DOMINICAN REPUBLIC (THE) + Dominican Peso + DOP + 214 + 2 + + + ECUADOR + US Dollar + USD + 840 + 2 + + + EGYPT + Egyptian Pound + EGP + 818 + 2 + + + EL SALVADOR + El Salvador Colon + SVC + 222 + 2 + + + EL SALVADOR + US Dollar + USD + 840 + 2 + + + EQUATORIAL GUINEA + CFA Franc BEAC + XAF + 950 + 0 + + + ERITREA + Nakfa + ERN + 232 + 2 + + + ESTONIA + Euro + EUR + 978 + 2 + + + ESWATINI + Lilangeni + SZL + 748 + 2 + + + ETHIOPIA + Ethiopian Birr + ETB + 230 + 2 + + + EUROPEAN UNION + Euro + EUR + 978 + 2 + + + FALKLAND ISLANDS (THE) [MALVINAS] + Falkland Islands Pound + FKP + 238 + 2 + + + FAROE ISLANDS (THE) + Danish Krone + DKK + 208 + 2 + + + FIJI + Fiji Dollar + FJD + 242 + 2 + + + FINLAND + Euro + EUR + 978 + 2 + + + FRANCE + Euro + EUR + 978 + 2 + + + FRENCH GUIANA + Euro + EUR + 978 + 2 + + + FRENCH POLYNESIA + CFP Franc + XPF + 953 + 0 + + + FRENCH SOUTHERN TERRITORIES (THE) + Euro + EUR + 978 + 2 + + + GABON + CFA Franc BEAC + XAF + 950 + 0 + + + GAMBIA (THE) + Dalasi + GMD + 270 + 2 + + + GEORGIA + Lari + GEL + 981 + 2 + + + GERMANY + Euro + EUR + 978 + 2 + + + GHANA + Ghana Cedi + GHS + 936 + 2 + + + GIBRALTAR + Gibraltar Pound + GIP + 292 + 2 + + + GREECE + Euro + EUR + 978 + 2 + + + GREENLAND + Danish Krone + DKK + 208 + 2 + + + GRENADA + East Caribbean Dollar + XCD + 951 + 2 + + + GUADELOUPE + Euro + EUR + 978 + 2 + + + GUAM + US Dollar + USD + 840 + 2 + + + GUATEMALA + Quetzal + GTQ + 320 + 2 + + + GUERNSEY + Pound Sterling + GBP + 826 + 2 + + + GUINEA + Guinean Franc + GNF + 324 + 0 + + + GUINEA-BISSAU + CFA Franc BCEAO + XOF + 952 + 0 + + + GUYANA + Guyana Dollar + GYD + 328 + 2 + + + HAITI + Gourde + HTG + 332 + 2 + + + HAITI + US Dollar + USD + 840 + 2 + + + HEARD ISLAND AND McDONALD ISLANDS + Australian Dollar + AUD + 036 + 2 + + + HOLY SEE (THE) + Euro + EUR + 978 + 2 + + + HONDURAS + Lempira + HNL + 340 + 2 + + + HONG KONG + Hong Kong Dollar + HKD + 344 + 2 + + + HUNGARY + Forint + HUF + 348 + 2 + + + ICELAND + Iceland Krona + ISK + 352 + 0 + + + INDIA + Indian Rupee + INR + 356 + 2 + + + INDONESIA + Rupiah + IDR + 360 + 2 + + + INTERNATIONAL MONETARY FUND (IMF)  + SDR (Special Drawing Right) + XDR + 960 + N.A. + + + IRAN (ISLAMIC REPUBLIC OF) + Iranian Rial + IRR + 364 + 2 + + + IRAQ + Iraqi Dinar + IQD + 368 + 3 + + + IRELAND + Euro + EUR + 978 + 2 + + + ISLE OF MAN + Pound Sterling + GBP + 826 + 2 + + + ISRAEL + New Israeli Sheqel + ILS + 376 + 2 + + + ITALY + Euro + EUR + 978 + 2 + + + JAMAICA + Jamaican Dollar + JMD + 388 + 2 + + + JAPAN + Yen + JPY + 392 + 0 + + + JERSEY + Pound Sterling + GBP + 826 + 2 + + + JORDAN + Jordanian Dinar + JOD + 400 + 3 + + + KAZAKHSTAN + Tenge + KZT + 398 + 2 + + + KENYA + Kenyan Shilling + KES + 404 + 2 + + + KIRIBATI + Australian Dollar + AUD + 036 + 2 + + + KOREA (THE DEMOCRATIC PEOPLE’S REPUBLIC OF) + North Korean Won + KPW + 408 + 2 + + + KOREA (THE REPUBLIC OF) + Won + KRW + 410 + 0 + + + KUWAIT + Kuwaiti Dinar + KWD + 414 + 3 + + + KYRGYZSTAN + Som + KGS + 417 + 2 + + + LAO PEOPLE’S DEMOCRATIC REPUBLIC (THE) + Lao Kip + LAK + 418 + 2 + + + LATVIA + Euro + EUR + 978 + 2 + + + LEBANON + Lebanese Pound + LBP + 422 + 2 + + + LESOTHO + Loti + LSL + 426 + 2 + + + LESOTHO + Rand + ZAR + 710 + 2 + + + LIBERIA + Liberian Dollar + LRD + 430 + 2 + + + LIBYA + Libyan Dinar + LYD + 434 + 3 + + + LIECHTENSTEIN + Swiss Franc + CHF + 756 + 2 + + + LITHUANIA + Euro + EUR + 978 + 2 + + + LUXEMBOURG + Euro + EUR + 978 + 2 + + + MACAO + Pataca + MOP + 446 + 2 + + + NORTH MACEDONIA + Denar + MKD + 807 + 2 + + + MADAGASCAR + Malagasy Ariary + MGA + 969 + 2 + + + MALAWI + Malawi Kwacha + MWK + 454 + 2 + + + MALAYSIA + Malaysian Ringgit + MYR + 458 + 2 + + + MALDIVES + Rufiyaa + MVR + 462 + 2 + + + MALI + CFA Franc BCEAO + XOF + 952 + 0 + + + MALTA + Euro + EUR + 978 + 2 + + + MARSHALL ISLANDS (THE) + US Dollar + USD + 840 + 2 + + + MARTINIQUE + Euro + EUR + 978 + 2 + + + MAURITANIA + Ouguiya + MRU + 929 + 2 + + + MAURITIUS + Mauritius Rupee + MUR + 480 + 2 + + + MAYOTTE + Euro + EUR + 978 + 2 + + + MEMBER COUNTRIES OF THE AFRICAN DEVELOPMENT BANK GROUP + ADB Unit of Account + XUA + 965 + N.A. + + + MEXICO + Mexican Peso + MXN + 484 + 2 + + + MEXICO + Mexican Unidad de Inversion (UDI) + MXV + 979 + 2 + + + MICRONESIA (FEDERATED STATES OF) + US Dollar + USD + 840 + 2 + + + MOLDOVA (THE REPUBLIC OF) + Moldovan Leu + MDL + 498 + 2 + + + MONACO + Euro + EUR + 978 + 2 + + + MONGOLIA + Tugrik + MNT + 496 + 2 + + + MONTENEGRO + Euro + EUR + 978 + 2 + + + MONTSERRAT + East Caribbean Dollar + XCD + 951 + 2 + + + MOROCCO + Moroccan Dirham + MAD + 504 + 2 + + + MOZAMBIQUE + Mozambique Metical + MZN + 943 + 2 + + + MYANMAR + Kyat + MMK + 104 + 2 + + + NAMIBIA + Namibia Dollar + NAD + 516 + 2 + + + NAMIBIA + Rand + ZAR + 710 + 2 + + + NAURU + Australian Dollar + AUD + 036 + 2 + + + NEPAL + Nepalese Rupee + NPR + 524 + 2 + + + NETHERLANDS (THE) + Euro + EUR + 978 + 2 + + + NEW CALEDONIA + CFP Franc + XPF + 953 + 0 + + + NEW ZEALAND + New Zealand Dollar + NZD + 554 + 2 + + + NICARAGUA + Cordoba Oro + NIO + 558 + 2 + + + NIGER (THE) + CFA Franc BCEAO + XOF + 952 + 0 + + + NIGERIA + Naira + NGN + 566 + 2 + + + NIUE + New Zealand Dollar + NZD + 554 + 2 + + + NORFOLK ISLAND + Australian Dollar + AUD + 036 + 2 + + + NORTHERN MARIANA ISLANDS (THE) + US Dollar + USD + 840 + 2 + + + NORWAY + Norwegian Krone + NOK + 578 + 2 + + + OMAN + Rial Omani + OMR + 512 + 3 + + + PAKISTAN + Pakistan Rupee + PKR + 586 + 2 + + + PALAU + US Dollar + USD + 840 + 2 + + + PALESTINE, STATE OF + No universal currency + + + PANAMA + Balboa + PAB + 590 + 2 + + + PANAMA + US Dollar + USD + 840 + 2 + + + PAPUA NEW GUINEA + Kina + PGK + 598 + 2 + + + PARAGUAY + Guarani + PYG + 600 + 0 + + + PERU + Sol + PEN + 604 + 2 + + + PHILIPPINES (THE) + Philippine Peso + PHP + 608 + 2 + + + PITCAIRN + New Zealand Dollar + NZD + 554 + 2 + + + POLAND + Zloty + PLN + 985 + 2 + + + PORTUGAL + Euro + EUR + 978 + 2 + + + PUERTO RICO + US Dollar + USD + 840 + 2 + + + QATAR + Qatari Rial + QAR + 634 + 2 + + + RÉUNION + Euro + EUR + 978 + 2 + + + ROMANIA + Romanian Leu + RON + 946 + 2 + + + RUSSIAN FEDERATION (THE) + Russian Ruble + RUB + 643 + 2 + + + RWANDA + Rwanda Franc + RWF + 646 + 0 + + + SAINT BARTHÉLEMY + Euro + EUR + 978 + 2 + + + SAINT HELENA, ASCENSION AND TRISTAN DA CUNHA + Saint Helena Pound + SHP + 654 + 2 + + + SAINT KITTS AND NEVIS + East Caribbean Dollar + XCD + 951 + 2 + + + SAINT LUCIA + East Caribbean Dollar + XCD + 951 + 2 + + + SAINT MARTIN (FRENCH PART) + Euro + EUR + 978 + 2 + + + SAINT PIERRE AND MIQUELON + Euro + EUR + 978 + 2 + + + SAINT VINCENT AND THE GRENADINES + East Caribbean Dollar + XCD + 951 + 2 + + + SAMOA + Tala + WST + 882 + 2 + + + SAN MARINO + Euro + EUR + 978 + 2 + + + SAO TOME AND PRINCIPE + Dobra + STN + 930 + 2 + + + SAUDI ARABIA + Saudi Riyal + SAR + 682 + 2 + + + SENEGAL + CFA Franc BCEAO + XOF + 952 + 0 + + + SERBIA + Serbian Dinar + RSD + 941 + 2 + + + SEYCHELLES + Seychelles Rupee + SCR + 690 + 2 + + + SIERRA LEONE + Leone + SLE + 925 + 2 + + + SINGAPORE + Singapore Dollar + SGD + 702 + 2 + + + SINT MAARTEN (DUTCH PART) + Caribbean Guilder + XCG + 532 + 2 + + + SISTEMA UNITARIO DE COMPENSACION REGIONAL DE PAGOS "SUCRE" + Sucre + XSU + 994 + N.A. + + + SLOVAKIA + Euro + EUR + 978 + 2 + + + SLOVENIA + Euro + EUR + 978 + 2 + + + SOLOMON ISLANDS + Solomon Islands Dollar + SBD + 090 + 2 + + + SOMALIA + Somali Shilling + SOS + 706 + 2 + + + SOUTH AFRICA + Rand + ZAR + 710 + 2 + + + SOUTH GEORGIA AND THE SOUTH SANDWICH ISLANDS + No universal currency + + + SOUTH SUDAN + South Sudanese Pound + SSP + 728 + 2 + + + SPAIN + Euro + EUR + 978 + 2 + + + SRI LANKA + Sri Lanka Rupee + LKR + 144 + 2 + + + SUDAN (THE) + Sudanese Pound + SDG + 938 + 2 + + + SURINAME + Surinam Dollar + SRD + 968 + 2 + + + SVALBARD AND JAN MAYEN + Norwegian Krone + NOK + 578 + 2 + + + SWEDEN + Swedish Krona + SEK + 752 + 2 + + + SWITZERLAND + Swiss Franc + CHF + 756 + 2 + + + SWITZERLAND + WIR Euro + CHE + 947 + 2 + + + SWITZERLAND + WIR Franc + CHW + 948 + 2 + + + SYRIAN ARAB REPUBLIC + Syrian Pound + SYP + 760 + 2 + + + TAIWAN (PROVINCE OF CHINA) + New Taiwan Dollar + TWD + 901 + 2 + + + TAJIKISTAN + Somoni + TJS + 972 + 2 + + + TANZANIA, UNITED REPUBLIC OF + Tanzanian Shilling + TZS + 834 + 2 + + + THAILAND + Baht + THB + 764 + 2 + + + TIMOR-LESTE + US Dollar + USD + 840 + 2 + + + TOGO + CFA Franc BCEAO + XOF + 952 + 0 + + + TOKELAU + New Zealand Dollar + NZD + 554 + 2 + + + TONGA + Pa’anga + TOP + 776 + 2 + + + TRINIDAD AND TOBAGO + Trinidad and Tobago Dollar + TTD + 780 + 2 + + + TUNISIA + Tunisian Dinar + TND + 788 + 3 + + + TÜRKİYE + Turkish Lira + TRY + 949 + 2 + + + TURKMENISTAN + Turkmenistan New Manat + TMT + 934 + 2 + + + TURKS AND CAICOS ISLANDS (THE) + US Dollar + USD + 840 + 2 + + + TUVALU + Australian Dollar + AUD + 036 + 2 + + + UGANDA + Uganda Shilling + UGX + 800 + 0 + + + UKRAINE + Hryvnia + UAH + 980 + 2 + + + UNITED ARAB EMIRATES (THE) + UAE Dirham + AED + 784 + 2 + + + UNITED KINGDOM OF GREAT BRITAIN AND NORTHERN IRELAND (THE) + Pound Sterling + GBP + 826 + 2 + + + UNITED STATES MINOR OUTLYING ISLANDS (THE) + US Dollar + USD + 840 + 2 + + + UNITED STATES OF AMERICA (THE) + US Dollar + USD + 840 + 2 + + + UNITED STATES OF AMERICA (THE) + US Dollar (Next day) + USN + 997 + 2 + + + URUGUAY + Peso Uruguayo + UYU + 858 + 2 + + + URUGUAY + Uruguay Peso en Unidades Indexadas (UI) + UYI + 940 + 0 + + + URUGUAY + Unidad Previsional + UYW + 927 + 4 + + + UZBEKISTAN + Uzbekistan Sum + UZS + 860 + 2 + + + VANUATU + Vatu + VUV + 548 + 0 + + + VENEZUELA (BOLIVARIAN REPUBLIC OF) + Bolívar Soberano + VES + 928 + 2 + + + VENEZUELA (BOLIVARIAN REPUBLIC OF) + Bolívar Soberano + VED + 926 + 2 + + + VIET NAM + Dong + VND + 704 + 0 + + + VIRGIN ISLANDS (BRITISH) + US Dollar + USD + 840 + 2 + + + VIRGIN ISLANDS (U.S.) + US Dollar + USD + 840 + 2 + + + WALLIS AND FUTUNA + CFP Franc + XPF + 953 + 0 + + + WESTERN SAHARA + Moroccan Dirham + MAD + 504 + 2 + + + YEMEN + Yemeni Rial + YER + 886 + 2 + + + ZAMBIA + Zambian Kwacha + ZMW + 967 + 2 + + + ZIMBABWE + Zimbabwe Gold + ZWG + 924 + 2 + + + ZZ01_Bond Markets Unit European_EURCO + Bond Markets Unit European Composite Unit (EURCO) + XBA + 955 + N.A. + + + ZZ02_Bond Markets Unit European_EMU-6 + Bond Markets Unit European Monetary Unit (E.M.U.-6) + XBB + 956 + N.A. + + + ZZ03_Bond Markets Unit European_EUA-9 + Bond Markets Unit European Unit of Account 9 (E.U.A.-9) + XBC + 957 + N.A. + + + ZZ04_Bond Markets Unit European_EUA-17 + Bond Markets Unit European Unit of Account 17 (E.U.A.-17) + XBD + 958 + N.A. + + + ZZ06_Testing_Code + Codes specifically reserved for testing purposes + XTS + 963 + N.A. + + + ZZ07_No_Currency + The codes assigned for transactions where no currency is involved + XXX + 999 + N.A. + + + ZZ08_Gold + Gold + XAU + 959 + N.A. + + + ZZ09_Palladium + Palladium + XPD + 964 + N.A. + + + ZZ10_Platinum + Platinum + XPT + 962 + N.A. + + + ZZ11_Silver + Silver + XAG + 961 + N.A. + + + \ No newline at end of file diff --git a/tools/data-sources/iso-639-2.txt b/tools/data-sources/iso-639-2.txt new file mode 100644 index 0000000..f1e4188 --- /dev/null +++ b/tools/data-sources/iso-639-2.txt @@ -0,0 +1,487 @@ +aar||aa|Afar|afar +abk||ab|Abkhazian|abkhaze +ace|||Achinese|aceh +ach|||Acoli|acoli +ada|||Adangme|adangme +ady|||Adyghe; Adygei|adyghé +afa|||Afro-Asiatic languages|afro-asiatiques, langues +afh|||Afrihili|afrihili +afr||af|Afrikaans|afrikaans +ain|||Ainu|aïnou +aka||ak|Akan|akan +akk|||Akkadian|akkadien +alb|sqi|sq|Albanian|albanais +ale|||Aleut|aléoute +alg|||Algonquian languages|algonquines, langues +alt|||Southern Altai|altai du Sud +amh||am|Amharic|amharique +ang|||English, Old (ca.450-1100)|anglo-saxon (ca.450-1100) +anp|||Angika|angika +apa|||Apache languages|apaches, langues +ara||ar|Arabic|arabe +arc|||Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)|araméen d'empire (700-300 BCE) +arg||an|Aragonese|aragonais +arm|hye|hy|Armenian|arménien +arn|||Mapudungun; Mapuche|mapudungun; mapuche; mapuce +arp|||Arapaho|arapaho +art|||Artificial languages|artificielles, langues +arw|||Arawak|arawak +asm||as|Assamese|assamais +ast|||Asturian; Bable; Leonese; Asturleonese|asturien; bable; léonais; asturoléonais +ath|||Athapascan languages|athapascanes, langues +aus|||Australian languages|australiennes, langues +ava||av|Avaric|avar +ave||ae|Avestan|avestique +awa|||Awadhi|awadhi +aym||ay|Aymara|aymara +aze||az|Azerbaijani|azéri +bad|||Banda languages|banda, langues +bai|||Bamileke languages|bamiléké, langues +bak||ba|Bashkir|bachkir +bal|||Baluchi|baloutchi +bam||bm|Bambara|bambara +ban|||Balinese|balinais +baq|eus|eu|Basque|basque +bas|||Basa|basa +bat|||Baltic languages|baltes, langues +bej|||Beja; Bedawiyet|bedja +bel||be|Belarusian|biélorusse +bem|||Bemba|bemba +ben||bn|Bengali|bengali +ber|||Berber languages|berbères, langues +bho|||Bhojpuri|bhojpuri +bih|||Bihari languages|langues biharis +bik|||Bikol|bikol +bin|||Bini; Edo|bini; edo +bis||bi|Bislama|bichlamar +bla|||Siksika|blackfoot +bnt|||Bantu languages|bantou, langues +bos||bs|Bosnian|bosniaque +bra|||Braj|braj +bre||br|Breton|breton +btk|||Batak languages|batak, langues +bua|||Buriat|bouriate +bug|||Buginese|bugi +bul||bg|Bulgarian|bulgare +bur|mya|my|Burmese|birman +byn|||Blin; Bilin|blin; bilen +cad|||Caddo|caddo +cai|||Central American Indian languages|amérindiennes de L'Amérique centrale, langues +car|||Galibi Carib|karib; galibi; carib +cat||ca|Catalan; Valencian|catalan; valencien +cau|||Caucasian languages|caucasiennes, langues +ceb|||Cebuano|cebuano +cel|||Celtic languages|celtiques, langues; celtes, langues +cha||ch|Chamorro|chamorro +chb|||Chibcha|chibcha +che||ce|Chechen|tchétchène +chg|||Chagatai|djaghataï +chi|zho|zh|Chinese|chinois +chk|||Chuukese|chuuk +chm|||Mari|mari +chn|||Chinook jargon|chinook, jargon +cho|||Choctaw|choctaw +chp|||Chipewyan; Dene Suline|chipewyan +chr|||Cherokee|cherokee +chu||cu|Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic|slavon d'église; vieux slave; slavon liturgique; vieux bulgare +chv||cv|Chuvash|tchouvache +chy|||Cheyenne|cheyenne +cmc|||Chamic languages|chames, langues +cnr|||Montenegrin|monténégrin +cop|||Coptic|copte +cor||kw|Cornish|cornique +cos||co|Corsican|corse +cpe|||Creoles and pidgins, English based|créoles et pidgins basés sur l'anglais +cpf|||Creoles and pidgins, French-based|créoles et pidgins basés sur le français +cpp|||Creoles and pidgins, Portuguese-based|créoles et pidgins basés sur le portugais +cre||cr|Cree|cree +crh|||Crimean Tatar; Crimean Turkish|tatar de Crimé +crp|||Creoles and pidgins|créoles et pidgins +csb|||Kashubian|kachoube +cus|||Cushitic languages|couchitiques, langues +cze|ces|cs|Czech|tchèque +dak|||Dakota|dakota +dan||da|Danish|danois +dar|||Dargwa|dargwa +day|||Land Dayak languages|dayak, langues +del|||Delaware|delaware +den|||Slave (Athapascan)|esclave (athapascan) +dgr|||Tlicho; Dogrib|tlicho; dogrib +din|||Dinka|dinka +div||dv|Divehi; Dhivehi; Maldivian|maldivien +doi|||Dogri|dogri +dra|||Dravidian languages|dravidiennes, langues +dsb|||Lower Sorbian|bas-sorabe +dua|||Duala|douala +dum|||Dutch, Middle (ca.1050-1350)|néerlandais moyen (ca. 1050-1350) +dut|nld|nl|Dutch; Flemish|néerlandais; flamand +dyu|||Dyula|dioula +dzo||dz|Dzongkha|dzongkha +efi|||Efik|efik +egy|||Egyptian (Ancient)|égyptien +eka|||Ekajuk|ekajuk +elx|||Elamite|élamite +eng||en|English|anglais +enm|||English, Middle (1100-1500)|anglais moyen (1100-1500) +epo||eo|Esperanto|espéranto +est||et|Estonian|estonien +ewe||ee|Ewe|éwé +ewo|||Ewondo|éwondo +fan|||Fang|fang +fao||fo|Faroese|féroïen +fat|||Fanti|fanti +fij||fj|Fijian|fidjien +fil|||Filipino; Pilipino|filipino; pilipino +fin||fi|Finnish|finnois +fiu|||Finno-Ugrian languages|finno-ougriennes, langues +fon|||Fon|fon +fre|fra|fr|French|français +frm|||French, Middle (ca.1400-1600)|français moyen (1400-1600) +fro|||French, Old (842-ca.1400)|français ancien (842-ca.1400) +frr|||Northern Frisian|frison septentrional +frs|||Eastern Frisian|frison oriental +fry||fy|Western Frisian|frison occidental +ful||ff|Fulah|peul +fur|||Friulian|frioulan +gaa|||Ga|ga +gay|||Gayo|gayo +gba|||Gbaya|gbaya +gem|||Germanic languages|germaniques, langues +geo|kat|ka|Georgian|géorgien +ger|deu|de|German|allemand +gez|||Geez|guèze +gil|||Gilbertese|kiribati +gla||gd|Gaelic; Scottish Gaelic|gaélique; gaélique écossais +gle||ga|Irish|irlandais +glg||gl|Galician|galicien +glv||gv|Manx|manx; mannois +gmh|||German, Middle High (ca.1050-1500)|allemand, moyen haut (ca. 1050-1500) +goh|||German, Old High (ca.750-1050)|allemand, vieux haut (ca. 750-1050) +gon|||Gondi|gond +gor|||Gorontalo|gorontalo +got|||Gothic|gothique +grb|||Grebo|grebo +grc|||Greek, Ancient (to 1453)|grec ancien (jusqu'à 1453) +gre|ell|el|Modern Greek (1453-)|grec moderne (1453-) +grn||gn|Guarani|guarani +gsw|||Swiss German; Alemannic; Alsatian|suisse alémanique; alémanique; alsacien +guj||gu|Gujarati|goudjrati +gwi|||Gwich'in|gwich'in +hai|||Haida|haida +hat||ht|Haitian; Haitian Creole|haïtien; créole haïtien +hau||ha|Hausa|haoussa +haw|||Hawaiian|hawaïen +heb||he|Hebrew|hébreu +her||hz|Herero|herero +hil|||Hiligaynon|hiligaynon +him|||Himachali languages; Western Pahari languages|langues himachalis; langues paharis occidentales +hin||hi|Hindi|hindi +hit|||Hittite|hittite +hmn|||Hmong; Mong|hmong +hmo||ho|Hiri Motu|hiri motu +hrv||hr|Croatian|croate +hsb|||Upper Sorbian|haut-sorabe +hun||hu|Hungarian|hongrois +hup|||Hupa|hupa +iba|||Iban|iban +ibo||ig|Igbo|igbo +ice|isl|is|Icelandic|islandais +ido||io|Ido|ido +iii||ii|Sichuan Yi; Nuosu|yi de Sichuan +ijo|||Ijo languages|ijo, langues +iku||iu|Inuktitut|inuktitut +ile||ie|Interlingue; Occidental|interlingue +ilo|||Iloko|ilocano +ina||ia|Interlingua (International Auxiliary Language Association)|interlingua (langue auxiliaire internationale) +inc|||Indic languages|indo-aryennes, langues +ind||id|Indonesian|indonésien +ine|||Indo-European languages|indo-européennes, langues +inh|||Ingush|ingouche +ipk||ik|Inupiaq|inupiaq +ira|||Iranian languages|iraniennes, langues +iro|||Iroquoian languages|iroquoises, langues +ita||it|Italian|italien +jav||jv|Javanese|javanais +jbo|||Lojban|lojban +jpn||ja|Japanese|japonais +jpr|||Judeo-Persian|judéo-persan +jrb|||Judeo-Arabic|judéo-arabe +kaa|||Kara-Kalpak|karakalpak +kab|||Kabyle|kabyle +kac|||Kachin; Jingpho|kachin; jingpho +kal||kl|Kalaallisut; Greenlandic|groenlandais +kam|||Kamba|kamba +kan||kn|Kannada|kannada +kar|||Karen languages|karen, langues +kas||ks|Kashmiri|kashmiri +kau||kr|Kanuri|kanouri +kaw|||Kawi|kawi +kaz||kk|Kazakh|kazakh +kbd|||Kabardian|kabardien +kha|||Khasi|khasi +khi|||Khoisan languages|khoïsan, langues +khm||km|Central Khmer|khmer central +kho|||Khotanese; Sakan|khotanais; sakan +kik||ki|Kikuyu; Gikuyu|kikuyu +kin||rw|Kinyarwanda|rwanda +kir||ky|Kirghiz; Kyrgyz|kirghiz +kmb|||Kimbundu|kimbundu +kok|||Konkani|konkani +kom||kv|Komi|kom +kon||kg|Kongo|kongo +kor||ko|Korean|coréen +kos|||Kosraean|kosrae +kpe|||Kpelle|kpellé +krc|||Karachay-Balkar|karatchai balkar +krl|||Karelian|carélien +kro|||Kru languages|krou, langues +kru|||Kurukh|kurukh +kua||kj|Kuanyama; Kwanyama|kuanyama; kwanyama +kum|||Kumyk|koumyk +kur||ku|Kurdish|kurde +kut|||Kutenai|kutenai +lad|||Ladino|judéo-espagnol +lah|||Lahnda|lahnda +lam|||Lamba|lamba +lao||lo|Lao|lao +lat||la|Latin|latin +lav||lv|Latvian|letton +lez|||Lezghian|lezghien +lim||li|Limburgan; Limburger; Limburgish|limbourgeois +lin||ln|Lingala|lingala +lit||lt|Lithuanian|lituanien +lol|||Mongo|mongo +loz|||Lozi|lozi +ltz||lb|Luxembourgish; Letzeburgesch|luxembourgeois +lua|||Luba-Lulua|luba-lulua +lub||lu|Luba-Katanga|luba-katanga +lug||lg|Ganda|ganda +lui|||Luiseno|luiseno +lun|||Lunda|lunda +luo|||Luo (Kenya and Tanzania)|luo (Kenya et Tanzanie) +lus|||Lushai|lushai +mac|mkd|mk|Macedonian|macédonien +mad|||Madurese|madourais +mag|||Magahi|magahi +mah||mh|Marshallese|marshall +mai|||Maithili|maithili +mak|||Makasar|makassar +mal||ml|Malayalam|malayalam +man|||Mandingo|mandingue +mao|mri|mi|Maori|maori +map|||Austronesian languages|austronésiennes, langues +mar||mr|Marathi|marathe +mas|||Masai|massaï +may|msa|ms|Malay|malais +mdf|||Moksha|moksa +mdr|||Mandar|mandar +men|||Mende|mendé +mga|||Irish, Middle (900-1200)|irlandais moyen (900-1200) +mic|||Mi'kmaq; Micmac|mi'kmaq; micmac +min|||Minangkabau|minangkabau +mis|||Uncoded languages|langues non codées +mkh|||Mon-Khmer languages|môn-khmer, langues +mlg||mg|Malagasy|malgache +mlt||mt|Maltese|maltais +mnc|||Manchu|mandchou +mni|||Manipuri|manipuri +mno|||Manobo languages|manobo, langues +moh|||Mohawk|mohawk +mon||mn|Mongolian|mongol +mos|||Mossi|moré +mul|||Multiple languages|multilingue +mun|||Munda languages|mounda, langues +mus|||Creek|muskogee +mwl|||Mirandese|mirandais +mwr|||Marwari|marvari +myn|||Mayan languages|maya, langues +myv|||Erzya|erza +nah|||Nahuatl languages|nahuatl, langues +nai|||North American Indian languages|nord-amérindiennes, langues +nap|||Neapolitan|napolitain +nau||na|Nauru|nauruan +nav||nv|Navajo; Navaho|navaho +nbl||nr|South Ndebele|ndébélé du Sud +nde||nd|North Ndebele|ndébélé du Nord +ndo||ng|Ndonga|ndonga +nds|||Low German; Low Saxon; German, Low; Saxon, Low|bas allemand; bas saxon; allemand, bas; saxon, bas +nep||ne|Nepali|népalais +new|||Nepal Bhasa; Newar; Newari|nepal bhasa; néwar; newari +nia|||Nias|nias +nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues +niu|||Niuean|niué +nno||nn|Norwegian Nynorsk|norvégien nynorsk +nob||nb|Norwegian Bokmål|norvégien bokmål +nog|||Nogai|nogaï; nogay +non|||Norse, Old|norrois, vieux +nor||no|Norwegian|norvégien +nqo|||N'Ko|n'ko +nso|||Pedi; Sepedi; Northern Sotho|pedi; sepedi; sotho du Nord +nub|||Nubian languages|nubiennes, langues +nwc|||Classical Newari; Old Newari; Classical Nepal Bhasa|newari classique +nya||ny|Chichewa; Chewa; Nyanja|chichewa; chewa; nyanja +nym|||Nyamwezi|nyamwezi +nyn|||Nyankole|nyankolé +nyo|||Nyoro|nyoro +nzi|||Nzima|nzema +oci||oc|Occitan (post 1500)|occitan (après 1500) +oji||oj|Ojibwa|ojibwa +ori||or|Oriya|oriya +orm||om|Oromo|galla +osa|||Osage|osage +oss||os|Ossetian; Ossetic|ossète +ota|||Turkish, Ottoman (1500-1928)|turc ottoman (1500-1928) +oto|||Otomian languages|otomi, langues +paa|||Papuan languages|papoues, langues +pag|||Pangasinan|pangasinan +pal|||Pahlavi|pahlavi +pam|||Pampanga; Kapampangan|pampangan +pan||pa|Panjabi; Punjabi|pendjabi +pap|||Papiamento|papiamento +pau|||Palauan|palau +peo|||Persian, Old (ca.600-400 B.C.)|perse, vieux (ca. 600-400 av. J.-C.) +per|fas|fa|Persian|persan +phi|||Philippine languages|philippines, langues +phn|||Phoenician|phénicien +pli||pi|Pali|pali +pol||pl|Polish|polonais +pon|||Pohnpeian|pohnpei +por||pt|Portuguese|portugais +pra|||Prakrit languages|prâkrit, langues +pro|||Provençal, Old (to 1500); Occitan, Old (to 1500)|provençal ancien (jusqu'à 1500); occitan ancien (jusqu'à 1500) +pus||ps|Pushto; Pashto|pachto +qaa-qtz|||Reserved for local use|réservée à l'usage local +que||qu|Quechua|quechua +raj|||Rajasthani|rajasthani +rap|||Rapanui|rapanui +rar|||Rarotongan; Cook Islands Maori|rarotonga; maori des îles Cook +roa|||Romance languages|romanes, langues +roh||rm|Romansh|romanche +rom|||Romany|tsigane +rum|ron|ro|Romanian; Moldavian; Moldovan|roumain; moldave +run||rn|Rundi|rundi +rup|||Aromanian; Arumanian; Macedo-Romanian|aroumain; macédo-roumain +rus||ru|Russian|russe +sad|||Sandawe|sandawe +sag||sg|Sango|sango +sah|||Yakut|iakoute +sai|||South American Indian languages|sud-amérindiennes, langues +sal|||Salishan languages|salishennes, langues +sam|||Samaritan Aramaic|samaritain +san||sa|Sanskrit|sanskrit +sas|||Sasak|sasak +sat|||Santali|santal +scn|||Sicilian|sicilien +sco|||Scots|écossais +sel|||Selkup|selkoupe +sem|||Semitic languages|sémitiques, langues +sga|||Irish, Old (to 900)|irlandais ancien (jusqu'à 900) +sgn|||Sign Languages|langues des signes +shn|||Shan|chan +sid|||Sidamo|sidamo +sin||si|Sinhala; Sinhalese|singhalais +sio|||Siouan languages|sioux, langues +sit|||Sino-Tibetan languages|sino-tibétaines, langues +sla|||Slavic languages|slaves, langues +slo|slk|sk|Slovak|slovaque +slv||sl|Slovenian|slovène +sma|||Southern Sami|sami du Sud +sme||se|Northern Sami|sami du Nord +smi|||Sami languages|sames, langues +smj|||Lule Sami|sami de Lule +smn|||Inari Sami|sami d'Inari +smo||sm|Samoan|samoan +sms|||Skolt Sami|sami skolt +sna||sn|Shona|shona +snd||sd|Sindhi|sindhi +snk|||Soninke|soninké +sog|||Sogdian|sogdien +som||so|Somali|somali +son|||Songhai languages|songhai, langues +sot||st|Sotho, Southern|sotho du Sud +spa||es|Spanish; Castilian|espagnol; castillan +srd||sc|Sardinian|sarde +srn|||Sranan Tongo|sranan tongo +srp||sr|Serbian|serbe +srr|||Serer|sérère +ssa|||Nilo-Saharan languages|nilo-sahariennes, langues +ssw||ss|Swati|swati +suk|||Sukuma|sukuma +sun||su|Sundanese|soundanais +sus|||Susu|soussou +sux|||Sumerian|sumérien +swa||sw|Swahili|swahili +swe||sv|Swedish|suédois +syc|||Classical Syriac|syriaque classique +syr|||Syriac|syriaque +tah||ty|Tahitian|tahitien +tai|||Tai languages|tai, langues +tam||ta|Tamil|tamoul +tat||tt|Tatar|tatar +tel||te|Telugu|télougou +tem|||Timne|temne +ter|||Tereno|tereno +tet|||Tetum|tetum +tgk||tg|Tajik|tadjik +tgl||tl|Tagalog|tagalog +tha||th|Thai|thaï +tib|bod|bo|Tibetan|tibétain +tig|||Tigre|tigré +tir||ti|Tigrinya|tigrigna +tiv|||Tiv|tiv +tkl|||Tokelau|tokelau +tlh|||Klingon; tlhIngan-Hol|klingon +tli|||Tlingit|tlingit +tmh|||Tamashek|tamacheq +tog|||Tonga (Nyasa)|tonga (Nyasa) +ton||to|Tonga (Tonga Islands)|tongan (Îles Tonga) +tpi|||Tok Pisin|tok pisin +tsi|||Tsimshian|tsimshian +tsn||tn|Tswana|tswana +tso||ts|Tsonga|tsonga +tuk||tk|Turkmen|turkmène +tum|||Tumbuka|tumbuka +tup|||Tupi languages|tupi, langues +tur||tr|Turkish|turc +tut|||Altaic languages|altaïques, langues +tvl|||Tuvalu|tuvalu +twi||tw|Twi|twi +tyv|||Tuvinian|touva +udm|||Udmurt|oudmourte +uga|||Ugaritic|ougaritique +uig||ug|Uighur; Uyghur|ouïgour +ukr||uk|Ukrainian|ukrainien +umb|||Umbundu|umbundu +und|||Undetermined|indéterminée +urd||ur|Urdu|ourdou +uzb||uz|Uzbek|ouszbek +vai|||Vai|vaï +ven||ve|Venda|venda +vie||vi|Vietnamese|vietnamien +vol||vo|Volapük|volapük +vot|||Votic|vote +wak|||Wakashan languages|wakashanes, langues +wal|||Wolaitta; Wolaytta|wolaitta; wolaytta +war|||Waray|waray +was|||Washo|washo +wel|cym|cy|Welsh|gallois +wen|||Sorbian languages|sorabes, langues +wln||wa|Walloon|wallon +wol||wo|Wolof|wolof +xal|||Kalmyk; Oirat|kalmouk; oïrat +xho||xh|Xhosa|xhosa +yao|||Yao|yao +yap|||Yapese|yapois +yid||yi|Yiddish|yiddish +yor||yo|Yoruba|yoruba +ypk|||Yupik languages|yupik, langues +zap|||Zapotec|zapotèque +zbl|||Blissymbols; Blissymbolics; Bliss|symboles Bliss; Bliss +zen|||Zenaga|zenaga +zgh|||Standard Moroccan Tamazight|amazighe standard marocain +zha||za|Zhuang; Chuang|zhuang; chuang +znd|||Zande languages|zandé, langues +zul||zu|Zulu|zoulou +zun|||Zuni|zuni +zxx|||No linguistic content; Not applicable|pas de contenu linguistique; non applicable +zza|||Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki|zaza; dimili; dimli; kirdki; kirmanjki; zazaki \ No newline at end of file From 1148d817f840204a1f2969a47450791ab68ef02b Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 23 Aug 2026 22:06:47 -0400 Subject: [PATCH 03/11] Add Asin, Ismn and UpcE with the checksums the legacy rules faked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three archive parity rules, each corrected in the port. ISMN validates the current 13-digit ISO 10957 form through the shared GTIN-13 checksum (an ISMN is an EAN-13 in the 979-0 block) instead of int-casting whatever characters arrive. ASIN was 'any 10 alphanumerics' upstream; it is now the B-prefixed uppercase shape or a checksum-verified ISBN-10. UPC-E's legacy checksum ran over the 8 compressed digits, but the check digit belongs to the EXPANDED UPC-A — the rule now expands through the GS1 zero-suppression patterns and verifies GTIN-12, pinned by the textbook 04252614/042100005264 pair. JAN and UPC-A are documented onto Ean and Gtin rather than duplicated as prefix-only classes. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 16 ++++- resources/lang/en/validation.php | 3 + src/Rules/Codes/Asin.php | 39 +++++++++++ src/Rules/Codes/Ean.php | 4 ++ src/Rules/Codes/Ismn.php | 36 +++++++++++ src/Rules/Codes/UpcE.php | 52 +++++++++++++++ src/Support/RuleAliases.php | 6 ++ tests/ConsoleCommandsTest.php | 2 +- tests/RuleRegistrarTest.php | 2 +- tests/Rules/Codes/NewCodesRulesTest.php | 86 +++++++++++++++++++++++++ 11 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/Rules/Codes/Asin.php create mode 100644 src/Rules/Codes/Ismn.php create mode 100644 src/Rules/Codes/UpcE.php create mode 100644 tests/Rules/Codes/NewCodesRulesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b32fb..59e9596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 57 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 60 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index c8266fb..7ac88bf 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -87,14 +87,26 @@ Cheap first filters. Neither replaces a CAPTCHA — `laranail/captcha` is that. | `Ean` | — | `ean` | `ean` | | `Isbn` | `array $editions = [10, 13]` | `isbn` | `isbn` | | `Issn` | — | `issn` | `issn` | +| `Asin` | — | `asin` | `asin` | +| `Ismn` | — | `ismn` | `ismn` | +| `UpcE` | — | `upc_e` | `upc_e` | - **`Gtin`** — a GS1 Global Trade Item Number. Pass `$lengths` to accept only some of GTIN-8, - -12, -13 and -14: `new Gtin([13])`, or `laranail_gtin:13`. -- **`Ean`** — a European Article Number, the retail barcode, in its 8 or 13 digit form. + -12, -13 and -14: `new Gtin([13])`, or `laranail_gtin:13`. A UPC-A is exactly a GTIN-12 — use + this rule for it. +- **`Ean`** — a European Article Number, the retail barcode, in its 8 or 13 digit form. A JAN + (Japanese Article Number) is an EAN-13 with GS1 prefix 45/49 — validate it with this rule. - **`Isbn`** — an International Standard Book Number (ISO 2108). Accepts both editions by default; `new Isbn([13])` restricts to ISBN-13. ISBN-10's check digit uses mod-11 with `X` as the value 10, which is why an `X` in the final position is valid and nowhere else. - **`Issn`** — an International Standard Serial Number (ISO 3297), same `X` escape. +- **`Asin`** — an Amazon Standard Identification Number: `B` + nine uppercase alphanumerics, or + a checksum-valid ISBN-10 (book ASINs are the book's ISBN). Amazon publishes no checksum for + the `B` form, so only its shape can be verified. +- **`Ismn`** — an International Standard Music Number (ISO 10957), the current 13-digit `9790…` + form with its GTIN-13 check digit; hyphens and spaces in the printed form are ignored. +- **`UpcE`** — the 8-digit zero-suppressed UPC. The check digit is verified against the + **expanded** UPC-A, which is the part quick implementations get wrong. ## Colour diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 15cd27c..d1eaa5b 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -21,9 +21,12 @@ 'luhn' => 'The :attribute must pass the Luhn checksum.', // Codes + 'asin' => 'The :attribute must be a valid ASIN.', 'ean' => 'The :attribute must be a valid EAN barcode.', 'gtin' => 'The :attribute must be a valid GTIN.', 'isbn' => 'The :attribute must be a valid ISBN.', + 'ismn' => 'The :attribute must be a valid ISMN.', + 'upc_e' => 'The :attribute must be a valid UPC-E barcode.', 'issn' => 'The :attribute must be a valid ISSN.', // Identifiers diff --git a/src/Rules/Codes/Asin.php b/src/Rules/Codes/Asin.php new file mode 100644 index 0000000..651b147 --- /dev/null +++ b/src/Rules/Codes/Asin.php @@ -0,0 +1,39 @@ +translate(); + } + } + + public static function passes(string $value): bool + { + if (preg_match('/^B[0-9A-Z]{9}$/D', $value) === 1) { + return true; + } + + return Isbn::passes($value, [10]); + } +} diff --git a/src/Rules/Codes/Ean.php b/src/Rules/Codes/Ean.php index 594cdfc..2bb8e08 100644 --- a/src/Rules/Codes/Ean.php +++ b/src/Rules/Codes/Ean.php @@ -13,6 +13,10 @@ * domain calls it, and because accepting a 12- or 14-digit GTIN where an EAN * is meant lets a shipping-carton code through as a retail one. * + * A JAN (Japanese Article Number) is exactly an EAN-13 whose GS1 prefix is + * 45 or 49 — validate it with this rule; there is deliberately no separate + * `Jan` class restating the same checksum for a prefix. + * * Pure tier — no IO. */ final class Ean implements ValidationRule diff --git a/src/Rules/Codes/Ismn.php b/src/Rules/Codes/Ismn.php new file mode 100644 index 0000000..ec8de82 --- /dev/null +++ b/src/Rules/Codes/Ismn.php @@ -0,0 +1,36 @@ +translate(); + } + } + + public static function passes(string $value): bool + { + $digits = str_replace(['-', ' '], '', $value); + + return str_starts_with($digits, '9790') && Gtin::passes($digits, [13]); + } +} diff --git a/src/Rules/Codes/UpcE.php b/src/Rules/Codes/UpcE.php new file mode 100644 index 0000000..6243b8c --- /dev/null +++ b/src/Rules/Codes/UpcE.php @@ -0,0 +1,52 @@ +translate(); + } + } + + public static function passes(string $value): bool + { + if (preg_match('/^[01]\d{7}$/D', $value) !== 1) { + return false; + } + + $numberSystem = $value[0]; + $data = substr($value, 1, 6); + $selector = $data[5]; + + // GS1's zero-suppression patterns, keyed by the sixth data digit. + $body = match (true) { + $selector === '0' || $selector === '1' || $selector === '2' => $numberSystem . substr($data, 0, 2) . $selector . '0000' . substr($data, 2, 3), + $selector === '3' => $numberSystem . substr($data, 0, 3) . '00000' . substr($data, 3, 2), + $selector === '4' => $numberSystem . substr($data, 0, 4) . '00000' . $data[4], + default => $numberSystem . substr($data, 0, 5) . '0000' . $selector, + }; + + return Gtin::passes($body . $value[7], [12]); + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 84ea419..95a6cf9 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -13,10 +13,13 @@ use Simtabi\Laranail\Validation\Rules\Banking\Iban; use Simtabi\Laranail\Validation\Rules\Banking\Isin; use Simtabi\Laranail\Validation\Rules\Banking\Luhn; +use Simtabi\Laranail\Validation\Rules\Codes\Asin; use Simtabi\Laranail\Validation\Rules\Codes\Ean; use Simtabi\Laranail\Validation\Rules\Codes\Gtin; use Simtabi\Laranail\Validation\Rules\Codes\Isbn; +use Simtabi\Laranail\Validation\Rules\Codes\Ismn; use Simtabi\Laranail\Validation\Rules\Codes\Issn; +use Simtabi\Laranail\Validation\Rules\Codes\UpcE; use Simtabi\Laranail\Validation\Rules\Colour\CssColor; use Simtabi\Laranail\Validation\Rules\Crypto\BitcoinAddress; use Simtabi\Laranail\Validation\Rules\Crypto\EthereumAddress; @@ -139,6 +142,9 @@ public static function map(): array 'issn' => static fn (): ValidationRule => new Issn(), 'isbn' => static fn (array $p): ValidationRule => new Isbn(self::ints($p)), 'gtin' => static fn (array $p): ValidationRule => new Gtin(self::ints($p)), + 'asin' => static fn (): ValidationRule => new Asin(), + 'ismn' => static fn (): ValidationRule => new Ismn(), + 'upc_e' => static fn (): ValidationRule => new UpcE(), // Identifiers 'imei' => static fn (): ValidationRule => new Imei(), diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 9f8c73d..99ae2a8 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('57 rules') + ->expectsOutputToContain('60 rules') ->assertSuccessful(); }); diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index e188801..1f20492 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(57); + ->and(count($classes))->toBe(60); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Codes/NewCodesRulesTest.php b/tests/Rules/Codes/NewCodesRulesTest.php new file mode 100644 index 0000000..21d01cd --- /dev/null +++ b/tests/Rules/Codes/NewCodesRulesTest.php @@ -0,0 +1,86 @@ +toBeTrue(); +})->with([ + '9790060115615', // a real published ISMN + '979-0-060-11561-5', + '979 0 060 11561 5', + '9790123456785', +]); + +it('rejects ISMNs with the wrong prefix, length or check digit', function (mixed $value): void { + expect(ruleAccepts(new Ismn(), $value))->toBeFalse(); +})->with([ + '9790060115614', // bad check digit + '9780060115616', // 978 is ISBN territory, not ISMN + '979006011561', // 12 digits + '97900601156155', // 14 digits + '979006011561a', // non-digit smuggled where (int) casts would hide it + 9790060115615, // integers are not accepted: barcodes are strings + null, +]); + +// ========================================================================= +// ASIN — B-prefixed Amazon id, or a checksum-valid ISBN-10 +// ========================================================================= + +it('accepts modern B-prefixed ASINs and ISBN-10 ASINs', function (string $value): void { + expect(ruleAccepts(new Asin(), $value))->toBeTrue(); +})->with([ + 'B01LYCLS24', + 'B000002L5R', + '0306406152', // valid ISBN-10 — books keep their ISBN as ASIN + '097522980X', // ISBN-10 with the X check digit +]); + +it('rejects lowercase, wrong-length and checksum-failing ASINs', function (mixed $value): void { + // The legacy rule accepted any 10 alphanumerics; the ISBN-10 branch has a + // checksum and the B branch is uppercase — both are enforced now. + expect(ruleAccepts(new Asin(), $value))->toBeFalse(); +})->with([ + 'b01lycls24', // lowercase + 'B01LYCLS2', // 9 chars + 'B01LYCLS245', // 11 chars + '0306406153', // ISBN-10 checksum fails + 'A123456789', // neither B-prefixed nor an ISBN-10 + 'B01LYC S24', // embedded space + 12345, + null, +]); + +// ========================================================================= +// UPC-E — 8 digits, number system 0/1, checksum of the EXPANDED UPC-A +// ========================================================================= + +it('accepts valid UPC-E codes across the expansion patterns', function (string $value): void { + expect(ruleAccepts(new UpcE(), $value))->toBeTrue(); +})->with([ + '04252614', // the textbook pair: expands to UPC-A 042100005264 + '01234531', // last-digit-3 expansion pattern + '17654346', // last-digit-4 pattern, number system 1 + '05555550', // last-digit-5..9 pattern +]); + +it('rejects UPC-E codes the legacy checksum would have passed', function (mixed $value): void { + // The check digit belongs to the EXPANDED UPC-A, not to the 8 compressed + // digits — checksumming the compressed form (as the legacy rule did) both + // accepts invalid codes and rejects valid ones. + expect(ruleAccepts(new UpcE(), $value))->toBeFalse(); +})->with([ + '04252615', // bad check digit + '24252614', // number system 2 does not exist in UPC-E + '0425261', // 7 digits + '042526144', // 9 digits + '0425261a', + 4252614, + null, +]); From 68a90fe79f3b48bd08737cfc91ca5d39a7f092f4 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 23 Aug 2026 22:11:55 -0400 Subject: [PATCH 04/11] Add word-count rules, the HtmlClean inverse and a data-backed Salutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaxWords/MinWords share one unicode word counter and split with PREG_SPLIT_NO_EMPTY — the legacy rule counted the empty fragments around surrounding whitespace, so ' two words ' was four. HtmlClean gains mustContainHtml: for the rich-text-editor-never-loaded case, mirroring its own encoded-markup and bare-< subtleties. Salutation validates against a bundled curated honorific list (case- and trailing-dot-insensitive), replaceable per rule instance, which is the localisation hook the legacy pheg() helper never had. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 12 ++- resources/data/salutations.txt | 78 ++++++++++++++++++++ resources/lang/en/validation.php | 4 + src/Commands/RulesCommand.php | 2 + src/Rules/Text/HtmlClean.php | 16 ++++ src/Rules/Text/MaxWords.php | 43 +++++++++++ src/Rules/Text/MinWords.php | 33 +++++++++ src/Rules/Text/Salutation.php | 60 +++++++++++++++ src/Support/RuleAliases.php | 9 ++- tests/ConsoleCommandsTest.php | 2 +- tests/RuleAliasesTest.php | 2 + tests/RuleRegistrarTest.php | 2 +- tests/Rules/Text/WordsAndSalutationTest.php | 81 +++++++++++++++++++++ 14 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 resources/data/salutations.txt create mode 100644 src/Rules/Text/MaxWords.php create mode 100644 src/Rules/Text/MinWords.php create mode 100644 src/Rules/Text/Salutation.php create mode 100644 tests/Rules/Text/WordsAndSalutationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e9596..8637d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 60 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 63 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 7ac88bf..7b97010 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -563,8 +563,11 @@ the alias map stays free of references to it. | `Username` | `int $min = 3, int $max = 32` | `username` | `username` | | `PersonName` | `bool $allowDigits = false` | `person_name` | `person_name` | | `CaseStyle` | `string $style` | `case_style` | `case_style.{style}` | -| `HtmlClean` | — | `html_clean` | `html_clean` | +| `HtmlClean` | `bool $mustContainHtml = false` | `html_clean` | `html_clean` / `contains_html` | | `WithoutSpaces` | — | `without_spaces` | `without_spaces` | +| `MaxWords` | `int $max` | `max_words` | `max_words` | +| `MinWords` | `int $min` | `min_words` | `min_words` | +| `Salutation` | `?array $accepted = null` | `salutation` | `salutation` | - **`Slug`** — lowercase alphanumerics separated by single hyphens, no leading or trailing one. - **`Username`** — letters, digits and single internal separators, within `$min`..`$max`; @@ -577,6 +580,13 @@ the alias map stays free of references to it. `SNAKE`, `TITLE`; as a string, `laranail_case_style:camel`. - **`HtmlClean`** — the value contains no HTML markup. A rejection, not a sanitiser: it tells the user their input was not accepted rather than silently rewriting it. + `mustContainHtml: true` (`laranail_html_clean:true`) inverts it — a rich-text field whose + plain-text submission means the editor never loaded. +- **`MaxWords`** / **`MinWords`** — word-count bounds. A word is a run of letters, numbers or + apostrophes in any script; hyphenated compounds split. `laranail_max_words:200`. +- **`Salutation`** — a recognised honorific (`Mr`, `Prof.`, `Madame`), case- and dot-insensitive. + The bundled list is a curated everyday set; pass your own `$accepted` (lowercase, no dots) to + replace it — that is also the localisation hook. - **`WithoutSpaces`** — no whitespace of any kind, including the Unicode separators a `\s`-based check misses. diff --git a/resources/data/salutations.txt b/resources/data/salutations.txt new file mode 100644 index 0000000..fde9997 --- /dev/null +++ b/resources/data/salutations.txt @@ -0,0 +1,78 @@ +# Salutations / honorifics accepted by Rules\Text\Salutation, one per line, +# lowercase with no trailing dot (the rule normalises input the same way). +# A curated everyday set — English forms plus the common courtesy titles of +# neighbouring languages. Pass a custom list to the rule to replace it. +mr +mister +mrs +missus +ms +miss +mx +master +madam +madame +mademoiselle +mlle +mme +sir +dame +lord +lady +dr +doctor +prof +professor +rev +reverend +pastor +fr +father +sr +sister +br +brother +elder +deacon +rabbi +imam +sheikh +hon +honourable +honorable +capt +captain +col +colonel +maj +major +lt +lieutenant +sgt +sergeant +gen +general +adm +admiral +cmdr +commander +amb +ambassador +pres +president +gov +governor +sen +senator +judge +justice +chief +eng +engineer +herr +frau +señor +señora +señorita +don +doña diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index d1eaa5b..536ed66 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -100,7 +100,11 @@ 'person_name_max' => 'The :attribute must not contain more than :max names.', 'person_name_required' => 'Please provide at least one of :values.', + 'contains_html' => 'The :attribute must contain HTML markup.', 'html_clean' => 'The :attribute must not contain HTML tags.', + 'max_words' => 'The :attribute must not contain more than :max words.', + 'min_words' => 'The :attribute must contain at least :min words.', + 'salutation' => 'The :attribute must be a recognised salutation.', 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', diff --git a/src/Commands/RulesCommand.php b/src/Commands/RulesCommand.php index b9f6c6f..6182b42 100644 --- a/src/Commands/RulesCommand.php +++ b/src/Commands/RulesCommand.php @@ -67,6 +67,8 @@ private function aliasIndex(): array 'parity' => ['even'], 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], + 'max_words' => ['200'], + 'min_words' => ['2'], ]; $index = []; diff --git a/src/Rules/Text/HtmlClean.php b/src/Rules/Text/HtmlClean.php index c767676..018fbd4 100644 --- a/src/Rules/Text/HtmlClean.php +++ b/src/Rules/Text/HtmlClean.php @@ -33,8 +33,24 @@ */ final class HtmlClean implements ValidationRule { + /** + * `mustContainHtml:` inverts the rule: the value must contain at least + * one real tag — a rich-text field whose empty-looking submission means + * the editor failed to load. The same subtleties hold mirrored: encoded + * markup and a bare `<` are prose, so they do not satisfy it. + */ + public function __construct(private readonly bool $mustContainHtml = false) {} + public function validate(string $attribute, mixed $value, Closure $fail): void { + if ($this->mustContainHtml) { + if (! is_string($value) || self::passes($value)) { + $fail('laranail-validation::validation.contains_html')->translate(); + } + + return; + } + if (! self::passes($value)) { $fail('laranail-validation::validation.html_clean')->translate(); } diff --git a/src/Rules/Text/MaxWords.php b/src/Rules/Text/MaxWords.php new file mode 100644 index 0000000..c97f525 --- /dev/null +++ b/src/Rules/Text/MaxWords.php @@ -0,0 +1,43 @@ + $this->max) { + $fail('laranail-validation::validation.max_words')->translate(['max' => $this->max]); + } + } + + /** How many words the value contains, shared with {@see MinWords}. */ + public static function count(string $value): int + { + $words = preg_split("~[^\p{L}\p{N}']+~u", $value, -1, PREG_SPLIT_NO_EMPTY); + + return $words === false ? 0 : count($words); + } +} diff --git a/src/Rules/Text/MinWords.php b/src/Rules/Text/MinWords.php new file mode 100644 index 0000000..456bd05 --- /dev/null +++ b/src/Rules/Text/MinWords.php @@ -0,0 +1,33 @@ +min) { + $fail('laranail-validation::validation.min_words')->translate(['min' => $this->min]); + } + } +} diff --git a/src/Rules/Text/Salutation.php b/src/Rules/Text/Salutation.php new file mode 100644 index 0000000..50925b3 --- /dev/null +++ b/src/Rules/Text/Salutation.php @@ -0,0 +1,60 @@ +|null */ + private ?array $accepted; + + /** @var array|null */ + private static ?array $bundled = null; + + /** + * @param list|null $accepted Replacement list, lowercase without dots. + */ + public function __construct(?array $accepted = null) + { + $this->accepted = $accepted === null ? null : array_fill_keys($accepted, true); + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value)) { + $fail('laranail-validation::validation.salutation')->translate(); + + return; + } + + $normalised = rtrim(mb_strtolower(trim($value)), '.'); + + if ($this->accepted === null) { + self::$bundled ??= CodeFile::load(dirname(__DIR__, 3) . '/resources/data/salutations.txt'); + } + + $list = $this->accepted ?? self::$bundled ?? []; + + if (! isset($list[$normalised])) { + $fail('laranail-validation::validation.salutation')->translate(); + } + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 95a6cf9..8270cce 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -60,7 +60,10 @@ use Simtabi\Laranail\Validation\Rules\Structure\Delimited; use Simtabi\Laranail\Validation\Rules\Text\CaseStyle; use Simtabi\Laranail\Validation\Rules\Text\HtmlClean; +use Simtabi\Laranail\Validation\Rules\Text\MaxWords; +use Simtabi\Laranail\Validation\Rules\Text\MinWords; use Simtabi\Laranail\Validation\Rules\Text\PersonName; +use Simtabi\Laranail\Validation\Rules\Text\Salutation; use Simtabi\Laranail\Validation\Rules\Text\Slug; use Simtabi\Laranail\Validation\Rules\Text\Username; use Simtabi\Laranail\Validation\Rules\Text\WithoutSpaces; @@ -184,7 +187,11 @@ public static function map(): array // Text 'slug' => static fn (): ValidationRule => new Slug(), - 'html_clean' => static fn (): ValidationRule => new HtmlClean(), + // `laranail_html_clean:true` inverts the rule: markup required. + 'html_clean' => static fn (array $p): ValidationRule => new HtmlClean(...self::flags($p)), + 'max_words' => static fn (array $p): ValidationRule => new MaxWords(...self::ints($p)), + 'min_words' => static fn (array $p): ValidationRule => new MinWords(...self::ints($p)), + 'salutation' => static fn (): ValidationRule => new Salutation(), 'without_spaces' => static fn (): ValidationRule => new WithoutSpaces(), 'case_style' => static fn (array $p): ValidationRule => new CaseStyle(self::str($p, 0)), 'person_name' => static fn (array $p): ValidationRule => new PersonName(...self::flags($p)), diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 99ae2a8..66f2d1d 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('60 rules') + ->expectsOutputToContain('63 rules') ->assertSuccessful(); }); diff --git a/tests/RuleAliasesTest.php b/tests/RuleAliasesTest.php index dda877e..0a04482 100644 --- a/tests/RuleAliasesTest.php +++ b/tests/RuleAliasesTest.php @@ -34,6 +34,8 @@ function sampleParameters(): array 'parity' => ['even'], 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], + 'max_words' => ['200'], + 'min_words' => ['2'], ]; } diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 1f20492..92a2a96 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(60); + ->and(count($classes))->toBe(63); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Text/WordsAndSalutationTest.php b/tests/Rules/Text/WordsAndSalutationTest.php new file mode 100644 index 0000000..249c1b4 --- /dev/null +++ b/tests/Rules/Text/WordsAndSalutationTest.php @@ -0,0 +1,81 @@ +toBeTrue() + ->and(ruleAccepts(new MaxWords(3), 'one two three four'))->toBeFalse() + ->and(ruleAccepts(new MaxWords(3), 'háblame más despacio'))->toBeTrue() + ->and(ruleAccepts(new MaxWords(1), "don't"))->toBeTrue(); +}); + +it('does not count surrounding whitespace as words', function (): void { + // The legacy splitter counted the empty fragments before and after the + // text, so " two words " was four and failed a max of two. + expect(ruleAccepts(new MaxWords(2), ' two words '))->toBeTrue() + ->and(ruleAccepts(new MinWords(2), ' two words '))->toBeTrue(); +}); + +it('enforces MinWords', function (): void { + expect(ruleAccepts(new MinWords(2), 'one two'))->toBeTrue() + ->and(ruleAccepts(new MinWords(2), 'one'))->toBeFalse() + ->and(ruleAccepts(new MinWords(1), 'word'))->toBeTrue(); +}); + +it('rejects non-strings for word counts', function (): void { + expect(ruleAccepts(new MaxWords(3), 12))->toBeFalse() + ->and(ruleAccepts(new MinWords(1), ['a b']))->toBeFalse(); +}); + +it('refuses a non-positive word bound', function (): void { + new MaxWords(0); +})->throws(LogicException::class); + +it('refuses a non-positive minimum word bound', function (): void { + new MinWords(0); +})->throws(LogicException::class); + +// ========================================================================= +// HtmlClean's inverse — requiring markup +// ========================================================================= + +it('can require markup instead of forbidding it', function (): void { + expect(ruleAccepts(new HtmlClean(mustContainHtml: true), '

rich

'))->toBeTrue() + ->and(ruleAccepts(new HtmlClean(mustContainHtml: true), 'plain text'))->toBeFalse() + // The same subtleties as the forbidding direction, mirrored: encoded + // markup and a bare `<` are prose, so they do not count as markup. + ->and(ruleAccepts(new HtmlClean(mustContainHtml: true), '<p>'))->toBeFalse() + ->and(ruleAccepts(new HtmlClean(mustContainHtml: true), '5 < 10'))->toBeFalse(); +}); + +it('keeps the forbidding default unchanged', function (): void { + expect(ruleAccepts(new HtmlClean(), 'plain text'))->toBeTrue() + ->and(ruleAccepts(new HtmlClean(), 'bold'))->toBeFalse(); +}); + +// ========================================================================= +// Salutation +// ========================================================================= + +it('accepts common salutations in any case, with or without the dot', function (string $value): void { + expect(ruleAccepts(new Salutation(), $value))->toBeTrue(); +})->with(['Mr', 'mr', 'Mr.', 'MRS', 'Prof.', 'Mx', 'Dr.', 'Madame']); + +it('rejects values that are not salutations', function (mixed $value): void { + expect(ruleAccepts(new Salutation(), $value))->toBeFalse(); +})->with(['xyz', 'Mister John', 'M r', 12, null]); + +it('validates against a custom salutation list when given one', function (): void { + $rule = new Salutation(['eng', 'mwl']); + + expect(ruleAccepts($rule, 'Eng.'))->toBeTrue() + ->and(ruleAccepts($rule, 'Mwl'))->toBeTrue() + ->and(ruleAccepts($rule, 'Mr'))->toBeFalse(); +}); From 17571b45fa60f46ee7742c485126b682aee49ce0 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 05:41:45 -0400 Subject: [PATCH 05/11] Add BsbNumber and HashDigest, both browser-checkable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BsbNumber is format-only on purpose — BSBs carry no check digit and the live-prefix register churns — but what is checkable is checked exactly, D-anchored against the trailing-newline class. HashDigest turns the legacy try/catch-any rule into a 28-algorithm width table: an unknown algorithm now throws at construction naming the known set, instead of silently failing every value at request time. Both advertise their own pattern through ClientCheckable, so the browser checks the same regex the server runs. --- CHANGELOG.md | 4 +- docs/tools/rule-library.md | 10 +++ resources/lang/en/validation.php | 2 + src/Commands/RulesCommand.php | 1 + src/Rules/Banking/BsbNumber.php | 37 ++++++++ src/Rules/Identifiers/HashDigest.php | 88 +++++++++++++++++++ src/Support/RuleAliases.php | 4 + tests/ConsoleCommandsTest.php | 2 +- tests/RuleAliasesTest.php | 1 + tests/RuleRegistrarTest.php | 4 +- tests/Rules/ClientCheckableTest.php | 2 + .../Identifiers/HashDigestAndBsbTest.php | 71 +++++++++++++++ 12 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src/Rules/Banking/BsbNumber.php create mode 100644 src/Rules/Identifiers/HashDigest.php create mode 100644 tests/Rules/Identifiers/HashDigestAndBsbTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8637d4f..6d35101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 63 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 65 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See @@ -45,7 +45,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se regex contorted into a numeric range. Implemented by `Text\Slug`, `Text\WithoutSpaces`, `Identifiers\SemVer`, `Net\Subdomain`, `Crypto\EthereumAddress`, `Text\CaseStyle`, `Text\Username`, `Text\PersonName`, `Numbers\MonetaryAmount`, `Vendor\VendorIdentifier`, - `Postal\PostalCode`, + `Postal\PostalCode`, `Banking\BsbNumber`, `Identifiers\HashDigest`, `Geo\Latitude`, `Geo\Longitude` and `Colour\CssColor` — the rules whose entire check is a pattern, which they return so there is no second implementation to drift. `PostalCode` sends only the named countries' patterns and nothing at all when the country comes from a sibling field. diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 7b97010..66fde5e 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -69,6 +69,7 @@ Cheap first filters. Neither replaces a CAPTCHA — `laranail/captcha` is that. | `Bic` | — | `bic` | `bic` | | `Isin` | — | `isin` | `isin` | | `Luhn` | — | `luhn` | `luhn` | +| `BsbNumber` | — | `bsb_number` | `bsb_number` | - **`Iban`** — an International Bank Account Number (ISO 13616). Checks the country's declared length before the ISO 7064 MOD-97-10 checksum, so a truncated number fails as a length error @@ -78,6 +79,9 @@ Cheap first filters. Neither replaces a CAPTCHA — `laranail/captcha` is that. prefix, nine alphanumerics, Luhn check digit over the letter-expanded string. - **`Luhn`** — the bare Luhn mod-10 checksum (ISO/IEC 7812-1), for card numbers and anything else carrying one. It checks the checksum only; it does not identify a card brand. +- **`BsbNumber`** — an Australian Bank State Branch number, `062-000` or `062000`. Format-only + and honestly so: BSBs carry no check digit, and the live-prefix register churns as banks + merge, so existence is the payment network's question. ## Codes @@ -304,6 +308,7 @@ codes) without touching the rules. |---|---|---|---| | `Imei` | — | `imei` | `imei` | | `Vin` | `bool $checkDigit = false` | `vin` | `vin` | +| `HashDigest` | `string $algorithm` | `hash_digest` | `hash_digest` | | `SemVer` | — | `semver` | `semver` | | `Jwt` | — | `jwt` | `jwt` | @@ -313,6 +318,11 @@ codes) without touching the rules. the North American position-9 check digit, which is **off by default** because it is not applied worldwide and would reject valid non-NA numbers. - **`SemVer`** — a Semantic Versioning 2.0.0 string, including prerelease and build metadata. +- **`HashDigest`** — a hex digest of a named algorithm (`new HashDigest('sha256')`, + `laranail_hash_digest:sha256`): hex in either case at exactly the algorithm's width, from a + table of 28 admitted algorithms. An unknown name throws at construction with the known names + in the message. Shape, not provenance — a well-formed digest says nothing about what was + hashed. - **`Jwt`** — a JSON Web Token in JWS compact serialisation (RFC 7515 §3.1). Structural only: three base64url segments with a decodable JSON header. It does **not** verify the signature and is not an authentication check. diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 536ed66..3b4336a 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -16,6 +16,7 @@ // Banking 'bic' => 'The :attribute must be a valid BIC/SWIFT code.', + 'bsb_number' => 'The :attribute must be a valid BSB number.', 'iban' => 'The :attribute must be a valid IBAN.', 'isin' => 'The :attribute must be a valid ISIN.', 'luhn' => 'The :attribute must pass the Luhn checksum.', @@ -30,6 +31,7 @@ 'issn' => 'The :attribute must be a valid ISSN.', // Identifiers + 'hash_digest' => 'The :attribute must be a :length-character hex digest.', 'imei' => 'The :attribute must be a valid IMEI.', 'jwt' => 'The :attribute must be a valid JSON Web Token.', 'semver' => 'The :attribute must be a valid semantic version.', diff --git a/src/Commands/RulesCommand.php b/src/Commands/RulesCommand.php index 6182b42..24a6588 100644 --- a/src/Commands/RulesCommand.php +++ b/src/Commands/RulesCommand.php @@ -67,6 +67,7 @@ private function aliasIndex(): array 'parity' => ['even'], 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], + 'hash_digest' => ['sha256'], 'max_words' => ['200'], 'min_words' => ['2'], ]; diff --git a/src/Rules/Banking/BsbNumber.php b/src/Rules/Banking/BsbNumber.php new file mode 100644 index 0000000..da8cf2e --- /dev/null +++ b/src/Rules/Banking/BsbNumber.php @@ -0,0 +1,37 @@ +translate(); + } + } + + public function clientRules(): array + { + return [['rule' => 'regex', 'params' => ['pattern' => self::PATTERN]]]; + } +} diff --git a/src/Rules/Identifiers/HashDigest.php b/src/Rules/Identifiers/HashDigest.php new file mode 100644 index 0000000..1dd9b5a --- /dev/null +++ b/src/Rules/Identifiers/HashDigest.php @@ -0,0 +1,88 @@ + 8, + 'crc32' => 8, + 'crc32b' => 8, + 'crc32c' => 8, + 'md4' => 32, + 'md5' => 32, + 'ripemd128' => 32, + 'ripemd160' => 40, + 'ripemd256' => 64, + 'ripemd320' => 80, + 'sha1' => 40, + 'sha224' => 56, + 'sha256' => 64, + 'sha384' => 96, + 'sha512' => 128, + 'sha512/224' => 56, + 'sha512/256' => 64, + 'sha3-224' => 56, + 'sha3-256' => 64, + 'sha3-384' => 96, + 'sha3-512' => 128, + 'tiger128' => 32, + 'tiger160' => 40, + 'tiger192' => 48, + 'whirlpool' => 128, + 'xxh32' => 8, + 'xxh64' => 16, + 'xxh128' => 32, + ]; + + private readonly int $length; + + public function __construct(string $algorithm) + { + $this->length = self::LENGTHS[strtolower($algorithm)] + ?? throw new InvalidArgumentException(sprintf( + 'Unknown hash algorithm [%s]. Known: %s.', + $algorithm, + implode(', ', array_keys(self::LENGTHS)), + )); + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || preg_match($this->pattern(), $value) !== 1) { + $fail('laranail-validation::validation.hash_digest')->translate(['length' => $this->length]); + } + } + + public function clientRules(): array + { + return [['rule' => 'regex', 'params' => ['pattern' => $this->pattern()]]]; + } + + private function pattern(): string + { + return '/^[a-fA-F0-9]{' . $this->length . '}$/D'; + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 8270cce..cd512b3 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -10,6 +10,7 @@ use Simtabi\Laranail\Validation\Rules\AntiSpam\Honeypot; use Simtabi\Laranail\Validation\Rules\AntiSpam\SubmissionTiming; use Simtabi\Laranail\Validation\Rules\Banking\Bic; +use Simtabi\Laranail\Validation\Rules\Banking\BsbNumber; use Simtabi\Laranail\Validation\Rules\Banking\Iban; use Simtabi\Laranail\Validation\Rules\Banking\Isin; use Simtabi\Laranail\Validation\Rules\Banking\Luhn; @@ -39,6 +40,7 @@ use Simtabi\Laranail\Validation\Rules\I18n\CountryCode; use Simtabi\Laranail\Validation\Rules\I18n\CurrencyCode; use Simtabi\Laranail\Validation\Rules\I18n\LanguageCode; +use Simtabi\Laranail\Validation\Rules\Identifiers\HashDigest; use Simtabi\Laranail\Validation\Rules\Identifiers\Imei; use Simtabi\Laranail\Validation\Rules\Identifiers\Jwt; use Simtabi\Laranail\Validation\Rules\Identifiers\SemVer; @@ -139,6 +141,7 @@ public static function map(): array 'bic' => static fn (): ValidationRule => new Bic(), 'isin' => static fn (): ValidationRule => new Isin(), 'luhn' => static fn (): ValidationRule => new Luhn(), + 'bsb_number' => static fn (): ValidationRule => new BsbNumber(), // Codes — the parameters narrow which lengths/editions are accepted. 'ean' => static fn (): ValidationRule => new Ean(), @@ -154,6 +157,7 @@ public static function map(): array 'jwt' => static fn (): ValidationRule => new Jwt(), 'semver' => static fn (): ValidationRule => new SemVer(), 'vin' => static fn (array $p): ValidationRule => new Vin(...self::flags($p)), + 'hash_digest' => static fn (array $p): ValidationRule => new HashDigest(self::str($p, 0)), // Geo 'latitude' => static fn (): ValidationRule => new Latitude(), diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 66f2d1d..5f166c3 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('63 rules') + ->expectsOutputToContain('65 rules') ->assertSuccessful(); }); diff --git a/tests/RuleAliasesTest.php b/tests/RuleAliasesTest.php index 0a04482..c7265f7 100644 --- a/tests/RuleAliasesTest.php +++ b/tests/RuleAliasesTest.php @@ -34,6 +34,7 @@ function sampleParameters(): array 'parity' => ['even'], 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], + 'hash_digest' => ['sha256'], 'max_words' => ['200'], 'min_words' => ['2'], ]; diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 92a2a96..7ab0a2a 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(63); + ->and(count($classes))->toBe(65); }); it('accepts a consumer registration with an alias factory', function (): void { @@ -55,7 +55,7 @@ expect($clientCheckable)->toContain(Slug::class) ->not->toContain(Iban::class) - ->and(count($clientCheckable))->toBe(14); + ->and(count($clientCheckable))->toBe(16); }); it('is one singleton, so every consumer sees the same registry', function (): void { diff --git a/tests/Rules/ClientCheckableTest.php b/tests/Rules/ClientCheckableTest.php index 8c0c7b8..48465e0 100644 --- a/tests/Rules/ClientCheckableTest.php +++ b/tests/Rules/ClientCheckableTest.php @@ -5,6 +5,7 @@ use Simtabi\Laranail\Validation\Rules\Colour\CssColor; use Simtabi\Laranail\Validation\Rules\Geo\Latitude; use Simtabi\Laranail\Validation\Rules\Geo\Longitude; +use Simtabi\Laranail\Validation\Rules\Identifiers\HashDigest; use Simtabi\Laranail\Validation\Rules\Postal\PostalCode; use Simtabi\Laranail\Validation\Rules\Text\CaseStyle; use Simtabi\Laranail\Validation\Rules\Vendor\VendorIdentifier; @@ -29,6 +30,7 @@ function clientCheckableArguments(): array return [ CaseStyle::class => [CaseStyle::KEBAB], VendorIdentifier::class => [VendorIdentifier::AWS_REGION], + HashDigest::class => ['sha256'], ]; } diff --git a/tests/Rules/Identifiers/HashDigestAndBsbTest.php b/tests/Rules/Identifiers/HashDigestAndBsbTest.php new file mode 100644 index 0000000..c3aa319 --- /dev/null +++ b/tests/Rules/Identifiers/HashDigestAndBsbTest.php @@ -0,0 +1,71 @@ +toBeTrue(); +})->with([ + ['md5', '4d74b56ed5e9cbd485fd7d7c08c7364a'], + ['sha256', '775b14201be10df3962d792fdf9570a221e4c694d291bced32a2350c1be89caf'], + ['crc32b', '4fc80341'], + ['sha3-256', 'a357e1d82864783929c155e323c77393ee0926d93294c4e2cfaf9b9041f132dd'], + ['xxh64', '15640581d7c9bf62'], + ['md5', '4D74B56ED5E9CBD485FD7D7C08C7364A'], // uppercase hex is the same digest +]); + +it('rejects wrong lengths, non-hex and trailing newlines', function (string $algorithm, mixed $digest): void { + expect(ruleAccepts(new HashDigest($algorithm), $digest))->toBeFalse(); +})->with([ + ['md5', '4d74b56ed5e9cbd485fd7d7c08c7364'], // 31 chars + ['sha256', '4d74b56ed5e9cbd485fd7d7c08c7364a'], // an md5 offered as sha256 + ['md5', '4d74b56ed5e9cbd485fd7d7c08c7364g'], // non-hex + ['md5', "4d74b56ed5e9cbd485fd7d7c08c7364a\n"], // the D-modifier class + ['md5', 12345], + ['md5', null], +]); + +it('refuses an unknown algorithm at construction', function (): void { + new HashDigest('sha42'); +})->throws(InvalidArgumentException::class, 'sha42'); + +it('advertises the digest pattern for the browser', function (): void { + $rules = new HashDigest('sha256')->clientRules(); + + expect($rules)->toHaveCount(1) + ->and($rules[0]['rule'])->toBe('regex') + ->and(preg_match($rules[0]['params']['pattern'], hash('sha256', 'x')))->toBe(1); +}); + +// ========================================================================= +// BsbNumber +// ========================================================================= + +it('accepts Australian BSB numbers with and without the hyphen', function (string $value): void { + expect(ruleAccepts(new BsbNumber(), $value))->toBeTrue(); +})->with(['062-000', '062000', '733100']); + +it('rejects malformed BSB numbers', function (mixed $value): void { + expect(ruleAccepts(new BsbNumber(), $value))->toBeFalse(); +})->with([ + '062-00', // five digits + '0620000', // seven digits + '062 000', // space is not the separator + '06-2000', // hyphen in the wrong place + "062-000\n", // the D-modifier class + 'abc-def', + 62000, + null, +]); + +it('advertises the BSB pattern for the browser', function (): void { + $rules = new BsbNumber()->clientRules(); + + expect($rules)->toHaveCount(1) + ->and(preg_match($rules[0]['params']['pattern'], '062-000'))->toBe(1) + ->and(preg_match($rules[0]['params']['pattern'], "062-000\n"))->toBe(0); +}); From 566d1d0a63350386f52af51971520d8b52256cb3 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 05:46:22 -0400 Subject: [PATCH 06/11] Add the Encoding family: Base64, Base64Image and DataUri MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base64 keeps the legacy round-trip core — the one thing it got right — because strict-decode-then-re-encode catches what a charset regex cannot: missing padding, embedded whitespace, non-zero discarded bits. It is deliberately not ClientCheckable for the same reason. Base64Image validates the DECODED bytes: canonical base64, an optional human-readable size cap, then finfo-sniffed MIME — never the data-URI label, which is attacker-written. The legacy rule's image path returned a closure instead of calling it ('return checkOne() || checkTwo;'), so it passed everything; its replacement is pinned by a real 1x1 PNG in both bare and data-URI forms. Support\Encoding\Base64File bridges the payload to an UploadedFile for Laravel's own file rules — outside Rules\ because it writes a temp file and the Pure tier forbids that. DataUri validates RFC 2397 shape and the payload against what the header claims; media-type restriction requires a declared type, since the implied text/plain default is what omitting the header gets free. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 24 +++++ resources/lang/en/validation.php | 6 ++ src/Rules/Encoding/Base64.php | 41 ++++++++ src/Rules/Encoding/Base64Image.php | 93 +++++++++++++++++++ src/Rules/Encoding/DataUri.php | 85 +++++++++++++++++ src/Support/Encoding/Base64File.php | 40 ++++++++ src/Support/RuleAliases.php | 13 +++ tests/ConsoleCommandsTest.php | 2 +- tests/RuleRegistrarTest.php | 2 +- tests/Rules/Encoding/EncodingRulesTest.php | 103 +++++++++++++++++++++ 11 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 src/Rules/Encoding/Base64.php create mode 100644 src/Rules/Encoding/Base64Image.php create mode 100644 src/Rules/Encoding/DataUri.php create mode 100644 src/Support/Encoding/Base64File.php create mode 100644 tests/Rules/Encoding/EncodingRulesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d35101..f26f4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 65 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 68 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 66fde5e..dfa6dae 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -217,6 +217,30 @@ Domain and mailbox rules. Deliverability needs DNS, so it lives in the Network t earns its place on the one kind of field that grants something per account, where one mailbox minting unlimited distinct addresses is how a single person takes a free trial repeatedly. +## Encoding + +| Rule | Parameters | Alias | Message key | +|---|---|---|---| +| `Base64` | — | `base64` | `base64` | +| `Base64Image` | `array $mimes = ['jpeg','png','gif','webp','bmp'], ?int $maxBytes = null` | *(none)* | `base64_image` / `base64_image_size` | +| `DataUri` | `array $mediaTypes = []` | *(none)* | `data_uri` | + +- **`Base64`** — canonical base64, verified by strict decode + re-encode round trip. The round + trip catches what a charset regex cannot: missing padding, embedded whitespace, and padding + with non-zero discarded bits (`aGVsbG9=` decodes, but nothing encodes *to* it). +- **`Base64Image`** — the payload a JavaScript cropper or `canvas.toDataURL()` posts, bare or + as a full data URI. Validates the **decoded bytes**: canonical base64, then the MIME type + sniffed from content (never from the attacker-written data-URI label). `$maxBytes` caps the + decoded size, and its message states the limit in human units ("2 MB"). To run Laravel's own + file rules (`dimensions`, `File::image()`) on the same payload, bridge it with + `Support\Encoding\Base64File::toUploadedFile()` — that writes a temp file, which is why it + lives outside `Rules\`. +- **`DataUri`** — an RFC 2397 data URI. The payload is validated for what the header claims: + strict base64 when flagged, URL-encoded text otherwise. `$mediaTypes` restricts the declared + type, exact or by family (`image/*`); a restriction requires a *declared* type, because the + RFC's implied `text/plain` default is what omitting the header gets for free. It checks the + declaration, not the content — use `Base64Image` when the bytes are the question. + ## Fiscal | Rule | Parameters | Alias | Message key | diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 3b4336a..7180d59 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -110,6 +110,12 @@ 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', + // Encoding + 'base64' => 'The :attribute must be a base64-encoded value.', + 'base64_image' => 'The :attribute must be a base64-encoded image of an accepted type.', + 'base64_image_size' => 'The :attribute must be an image no larger than :max.', + 'data_uri' => 'The :attribute must be a valid data URI.', + // I18n 'country_code' => 'The :attribute must be a valid ISO 3166-1 country code.', 'currency_code' => 'The :attribute must be a valid ISO 4217 currency code.', diff --git a/src/Rules/Encoding/Base64.php b/src/Rules/Encoding/Base64.php new file mode 100644 index 0000000..3e431ee --- /dev/null +++ b/src/Rules/Encoding/Base64.php @@ -0,0 +1,41 @@ +translate(); + } + } + + public static function passes(string $value): bool + { + $decoded = base64_decode($value, true); + + return $decoded !== false && base64_encode($decoded) === $value; + } +} diff --git a/src/Rules/Encoding/Base64Image.php b/src/Rules/Encoding/Base64Image.php new file mode 100644 index 0000000..aa8d7c6 --- /dev/null +++ b/src/Rules/Encoding/Base64Image.php @@ -0,0 +1,93 @@ +max()`) + * against the same payload, bridge it with + * {@see Base64File::toUploadedFile()} + * — that writes a temp file, which is why it lives outside `Rules\`. + * + * Pure tier — the sniff reads the in-memory buffer; nothing touches disk. + */ +final class Base64Image implements ValidationRule +{ + /** + * @param list $mimes Accepted `image/*` subtypes. + */ + public function __construct( + private readonly array $mimes = ['jpeg', 'png', 'gif', 'webp', 'bmp'], + private readonly ?int $maxBytes = null, + ) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $bytes = self::decode($value); + + if ($bytes === null) { + $fail('laranail-validation::validation.base64_image')->translate(); + + return; + } + + if ($this->maxBytes !== null && strlen($bytes) > $this->maxBytes) { + $fail('laranail-validation::validation.base64_image_size') + ->translate(['max' => Number::fileSize($this->maxBytes)]); + + return; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = $finfo === false ? false : finfo_buffer($finfo, $bytes); + $subtype = is_string($mime) && str_starts_with($mime, 'image/') + ? substr($mime, strlen('image/')) + : null; + + if ($subtype === null || ! in_array($subtype, $this->mimes, true)) { + $fail('laranail-validation::validation.base64_image')->translate(); + } + } + + /** + * The decoded bytes, from the bare or data-URI form — null when the + * value is not canonical base64. + */ + public static function decode(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + if (str_starts_with($value, 'data:')) { + $comma = strpos($value, ','); + + if ($comma === false || ! str_contains(substr($value, 0, $comma), ';base64')) { + return null; + } + + $value = substr($value, $comma + 1); + } + + if (! Base64::passes($value)) { + return null; + } + + return (string) base64_decode($value, true); + } +} diff --git a/src/Rules/Encoding/DataUri.php b/src/Rules/Encoding/DataUri.php new file mode 100644 index 0000000..1bc6010 --- /dev/null +++ b/src/Rules/Encoding/DataUri.php @@ -0,0 +1,85 @@ +][;base64],`. + * + * Both the media type and the `;base64` flag are optional; the comma is + * not. What follows it is validated for what the header CLAIMS it is — + * strict canonical base64 when flagged (via {@see Base64}), URL-encoded + * text otherwise (a raw space fails; `%20` passes). A media type must be a + * full `type/subtype`; `;base64` is distinguishable from a parameter + * because parameters carry `=`. + * + * `$mediaTypes` restricts what the URI may declare — exact (`image/png`) + * or by family (`image/*`). Restriction requires a DECLARED type: the + * RFC's implied `text/plain` default is what an attacker gets for free by + * omitting the header, so an untyped URI does not qualify. Note this + * matches the declaration, not the payload — sniff the decoded bytes + * ({@see Base64Image}) when the content itself is the question. + * + * Pure tier — no IO. + */ +final class DataUri implements ValidationRule +{ + private const string SHAPE = + '/^data:(?[\w!#$&^.+-]+\/[\w!#$&^.+-]+(?:;[\w-]+=[^;,]+)*)?(?;base64)?,(?.*)$/isD'; + + private const string URL_ENCODED = "/^(?:[A-Za-z0-9\\-_.~!$&'()*+,;=:@\\/?]|%[0-9A-Fa-f]{2})*$/D"; + + /** + * @param list $mediaTypes Accepted declared types, exact or `family/*`. + */ + public function __construct(private readonly array $mediaTypes = []) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.data_uri')->translate(); + } + } + + private function passes(string $value): bool + { + if (preg_match(self::SHAPE, $value, $parts) !== 1) { + return false; + } + + $declared = $parts['media'] === '' ? null : strtolower(explode(';', $parts['media'])[0]); + + if ($this->mediaTypes !== [] && ! $this->declaredTypeAllowed($declared)) { + return false; + } + + if ($parts['b64'] !== '') { + return Base64::passes($parts['data']); + } + + return preg_match(self::URL_ENCODED, $parts['data']) === 1; + } + + private function declaredTypeAllowed(?string $declared): bool + { + if ($declared === null) { + return false; + } + + foreach ($this->mediaTypes as $allowed) { + $allowed = strtolower($allowed); + + if ($declared === $allowed) { + return true; + } + + if (str_ends_with($allowed, '/*') && str_starts_with($declared, substr($allowed, 0, -1))) { + return true; + } + } + + return false; + } +} diff --git a/src/Support/Encoding/Base64File.php b/src/Support/Encoding/Base64File.php new file mode 100644 index 0000000..af532fa --- /dev/null +++ b/src/Support/Encoding/Base64File.php @@ -0,0 +1,40 @@ +max()`, `mimes` — can run + * against what a JavaScript cropper posted as text. + * + * Lives in `Support\`, not `Rules\`, because it writes a temp file and the + * Pure tier forbids rules from touching disk. The file lands in the system + * temp directory and follows PHP's temp-file lifecycle; validate first + * (e.g. {@see Base64Image}) if the payload is untrusted, and mind the + * decoded size — this materialises the whole payload. + */ +final class Base64File +{ + /** + * @param string $name The client filename the rules will see. + */ + public static function toUploadedFile(mixed $value, string $name = 'upload'): ?UploadedFile + { + $bytes = Base64Image::decode($value); + + if ($bytes === null) { + return null; + } + + $path = tempnam(sys_get_temp_dir(), 'laranail-b64'); + + if ($path === false || file_put_contents($path, $bytes) === false) { + return null; + } + + return new UploadedFile($path, $name, test: true); + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index cd512b3..b55c704 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -31,6 +31,9 @@ use Simtabi\Laranail\Validation\Rules\Email\NoSubaddressing; use Simtabi\Laranail\Validation\Rules\Email\NotDisposableEmail; use Simtabi\Laranail\Validation\Rules\Email\NotRoleEmail; +use Simtabi\Laranail\Validation\Rules\Encoding\Base64; +use Simtabi\Laranail\Validation\Rules\Encoding\Base64Image; +use Simtabi\Laranail\Validation\Rules\Encoding\DataUri; use Simtabi\Laranail\Validation\Rules\Fiscal\NationalIdentifier; use Simtabi\Laranail\Validation\Rules\Geo\CaProvince; use Simtabi\Laranail\Validation\Rules\Geo\Latitude; @@ -128,6 +131,12 @@ final class RuleAliases // dangerous half of the rule: the shape check without the host and // credential checks anyone reading `laranail_url` would assume. Url::class, + // Base64Image mixes a subtype list with a byte cap and DataUri takes + // a media-type list — same flat-tail problem as Url, and an alias + // exposing only the defaults would invite `laranail_base64_image` + // with the caller assuming their own constraints somehow apply. + Base64Image::class, + DataUri::class, ]; /** @@ -170,6 +179,10 @@ public static function map(): array // to read the country from a sibling field. 'postal_code' => self::postalCode(...), + // Encoding — Base64Image and DataUri take lists and a byte cap, + // which a flat rule string cannot spell honestly (see UNALIASED). + 'base64' => static fn (): ValidationRule => new Base64(), + // I18n — positional flags mirror the constructors: // `laranail_country_code:true` is alpha-3 mode, // `laranail_currency_code:true` numeric, `:false,true` symbol. diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 5f166c3..1200322 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('65 rules') + ->expectsOutputToContain('68 rules') ->assertSuccessful(); }); diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 7ab0a2a..8f4c0a6 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(65); + ->and(count($classes))->toBe(68); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Encoding/EncodingRulesTest.php b/tests/Rules/Encoding/EncodingRulesTest.php new file mode 100644 index 0000000..736411d --- /dev/null +++ b/tests/Rules/Encoding/EncodingRulesTest.php @@ -0,0 +1,103 @@ +toBeTrue(); +})->with(['aGVsbG8=', 'aGVsbG8gd29ybGQ=', PNG_B64]); + +it('rejects non-canonical and malformed base64', function (mixed $value): void { + // Round-tripping catches what a charset regex cannot: missing padding, + // embedded whitespace, and non-zero discarded bits ('aGVsbG9=' decodes, + // but nothing ever encodes TO it). + expect(ruleAccepts(new Base64(), $value))->toBeFalse(); +})->with(['aGVsbG9', "aGVsbG8=\n", 'aGVsbG 8=', '!!!!', 'aGVsbG9=', 12345, null]); + +// ========================================================================= +// Base64Image — decoded and sniffed, bare or data-URI form +// ========================================================================= + +it('accepts a real image, bare and as a data URI', function (): void { + expect(ruleAccepts(new Base64Image(), PNG_B64))->toBeTrue() + ->and(ruleAccepts(new Base64Image(), 'data:image/png;base64,' . PNG_B64))->toBeTrue(); +}); + +it('rejects non-images and images outside the allowed types', function (): void { + // 'aGVsbG8=' is valid base64 of text — the MIME sniff is what rejects it. + expect(ruleAccepts(new Base64Image(), 'aGVsbG8='))->toBeFalse() + ->and(ruleAccepts(new Base64Image(mimes: ['jpeg']), PNG_B64))->toBeFalse() + ->and(ruleAccepts(new Base64Image(), 'not base64 at all'))->toBeFalse() + ->and(ruleAccepts(new Base64Image(), 12345))->toBeFalse(); +}); + +it('enforces the decoded size cap with a human-readable message', function (): void { + expect(ruleAccepts(new Base64Image(maxBytes: 69), PNG_B64))->toBeFalse() + ->and(ruleAccepts(new Base64Image(maxBytes: 70), PNG_B64))->toBeTrue(); + + $validator = Validator::make( + ['f' => PNG_B64], + ['f' => new Base64Image(maxBytes: 42)], + ); + + // The size is stated in units a person reads, not raw bytes. + expect($validator->errors()->first('f'))->toContain('42 B'); +}); + +it('bridges a validated image to an UploadedFile for Laravel file rules', function (): void { + $file = Base64File::toUploadedFile('data:image/png;base64,' . PNG_B64, 'avatar.png'); + + expect($file)->not->toBeNull() + ->and($file->getSize())->toBe(70) + ->and(Validator::make( + ['f' => $file], + ['f' => 'image'], + )->passes())->toBeTrue(); + + expect(Base64File::toUploadedFile('not base64'))->toBeNull(); +}); + +// ========================================================================= +// DataUri — RFC 2397 +// ========================================================================= + +it('accepts well-formed data URIs', function (string $value): void { + expect(ruleAccepts(new DataUri(), $value))->toBeTrue(); +})->with([ + 'data:image/png;base64,' . PNG_B64, + 'data:text/plain,hello%20world', + 'data:,bare%20form', // both parts are optional + 'data:text/plain;charset=utf-8,hi', +]); + +it('rejects malformed data URIs', function (mixed $value): void { + expect(ruleAccepts(new DataUri(), $value))->toBeFalse(); +})->with([ + 'data:image/png;base64,!!!!', // base64 flag with non-base64 payload + 'data:image/png;base64', // no comma, no data + 'dat:image/png;base64,' . PNG_B64, // scheme typo + 'data:image;base64,abcd', // media type missing its subtype + 'data:text/plain,hello world', // raw space is not URL-encoded + 12345, + null, +]); + +it('can restrict the media types it accepts', function (): void { + $imagesOnly = new DataUri(mediaTypes: ['image/*']); + + expect(ruleAccepts($imagesOnly, 'data:image/png;base64,' . PNG_B64))->toBeTrue() + ->and(ruleAccepts($imagesOnly, 'data:text/plain,hi'))->toBeFalse() + // Restricting to a type means an untyped URI no longer qualifies. + ->and(ruleAccepts($imagesOnly, 'data:,hi'))->toBeFalse() + ->and(ruleAccepts(new DataUri(mediaTypes: ['text/plain']), 'data:text/plain,hi'))->toBeTrue(); +}); From 580efcbc9dab272732068ebe49b616c3b82b9b51 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 05:51:41 -0400 Subject: [PATCH 07/11] Add the Chrono family: eight time rules the archive carried as traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rfc3339 validates the grammar PHP's own RFC3339 format rejects (Z, lowercase t/z, fractions, the leap second) and then checks the date against the calendar, so 2023-02-29 fails as a date instead of passing as a shape. TimeOfDay folds the 12/24-hour pair into one rule with a mode and requires the meridiem in 12-hour form — a bare 9:05 on a 12-hour clock is ambiguous. UnixTimestamp accepts the canonical integer form only, not everything is_numeric waves through. DateInterval defers to PHP's parser (the definition that matters, since the value feeds it next) with positive: rejecting the zero duration. MinuteIn pins schedule-slot minutes. MaxDateDifference bounds the absolute distance from a fixed instant or an @sibling field, and fails when the reference is missing — a bound that silently stopped binding is the worse outcome. TimezoneAbbreviation reads PHP's own abbreviation table instead of the legacy 28-entry list. MinimumAge measures completed years in a named timezone, because at 20:00 UTC on the 23rd the same person is 17 in London and 18 in Auckland. Owner decision recorded in the audit: these live in-core (not on laranail/chrono), with the package floor raised to ^8.5. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 39 ++++ resources/lang/en/validation.php | 11 ++ src/Commands/RulesCommand.php | 3 + src/Rules/Chrono/DateInterval.php | 48 +++++ src/Rules/Chrono/MaxDateDifference.php | 80 +++++++++ src/Rules/Chrono/MinimumAge.php | 56 ++++++ src/Rules/Chrono/MinuteIn.php | 60 +++++++ src/Rules/Chrono/Rfc3339.php | 43 +++++ src/Rules/Chrono/TimeOfDay.php | 47 +++++ src/Rules/Chrono/TimezoneAbbreviation.php | 37 ++++ src/Rules/Chrono/UnixTimestamp.php | 49 +++++ src/Support/RuleAliases.php | 22 +++ tests/ConsoleCommandsTest.php | 2 +- tests/RuleAliasesTest.php | 3 + tests/RuleRegistrarTest.php | 2 +- tests/Rules/Chrono/ChronoRulesTest.php | 206 ++++++++++++++++++++++ 17 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 src/Rules/Chrono/DateInterval.php create mode 100644 src/Rules/Chrono/MaxDateDifference.php create mode 100644 src/Rules/Chrono/MinimumAge.php create mode 100644 src/Rules/Chrono/MinuteIn.php create mode 100644 src/Rules/Chrono/Rfc3339.php create mode 100644 src/Rules/Chrono/TimeOfDay.php create mode 100644 src/Rules/Chrono/TimezoneAbbreviation.php create mode 100644 src/Rules/Chrono/UnixTimestamp.php create mode 100644 tests/Rules/Chrono/ChronoRulesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f26f4d6..0dcf5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 68 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 76 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index dfa6dae..153b682 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -112,6 +112,45 @@ Cheap first filters. Neither replaces a CAPTCHA — `laranail/captcha` is that. - **`UpcE`** — the 8-digit zero-suppressed UPC. The check digit is verified against the **expanded** UPC-A, which is the part quick implementations get wrong. +## Chrono + +| Rule | Parameters | Alias | Message key | +|---|---|---|---| +| `Rfc3339` | — | `rfc3339` | `rfc3339` | +| `TimeOfDay` | `bool $twelveHour = false, string $separator = ':'` | `time_of_day` | `time_of_day` | +| `UnixTimestamp` | `bool $allowNegative = false` | `unix_timestamp` | `unix_timestamp` | +| `DateInterval` | `bool $positive = false` | `date_interval` | `date_interval` / `date_interval_positive` | +| `MinuteIn` | `array $minutes` | `minute_in` | `minute_in` | +| `MaxDateDifference` | `int $hours, DateTimeInterface\|string $from` | `max_date_difference` | `max_date_difference` | +| `TimezoneAbbreviation` | — | `timezone_abbreviation` | `timezone_abbreviation` | +| `MinimumAge` | `int $years, ?string $timezone = null` | `minimum_age` | `minimum_age` | + +- **`Rfc3339`** — a full RFC 3339 timestamp, including the forms PHP's own + `createFromFormat(RFC3339, …)` fumbles: `Z`, lowercase `t`/`z`, fractional seconds, and the + leap second `:60`. The date part is checked against the calendar, so `2023-02-29` fails as a + date, not passes as a shape. Laravel's `date_format` cannot express "either `Z` or an offset". +- **`TimeOfDay`** — `23:59` / `23:59:59`, or with `twelveHour: true` the meridiem form + (`9:05 PM`, meridiem required — a bare `9:05` on a 12-hour form is ambiguous). `$separator` + covers locales that write `23.59`. +- **`UnixTimestamp`** — a canonical integer timestamp: no floats, no leading zeros, no `+`, no + `1e9` (all of which `is_numeric` waves through). Pre-epoch values are opt-in + (`allowNegative: true`). Deliberately no range cap — plausibility is the field's business + rule, not the encoding's. +- **`DateInterval`** — an ISO 8601 duration as PHP's own `DateInterval` parses it. + `positive: true` also rejects the zero duration (`P0Y`), the retention-period-of-nothing case. +- **`MinuteIn`** — the minute component is in the allowed set: `new MinuteIn([0, 15, 30, 45])` + for quarter-hour scheduling. Takes a bare time or a full datetime. +- **`MaxDateDifference`** — the value lies within `$hours` of a reference, in either direction. + The reference is a date, a `DateTimeInterface`, or `@field` to read a sibling + (`new MaxDateDifference(48, '@start_at')`). A missing or unparseable reference **fails** the + value — a bound that silently stopped binding is the worse outcome. +- **`TimezoneAbbreviation`** — `EST`, `CET`, `EAT`: the abbreviation set from PHP's own + timezone database, which Laravel's identifier-validating `timezone` rule rejects. Prefer + identifiers in new schemas; validate existing data as what it is. +- **`MinimumAge`** — a date of birth at least `$years` completed years ago. Age is + timezone-dependent (at 20:00 UTC on the 23rd it is already the 24th in Auckland), so + `$timezone` names whose midnight the birthday ticks over at. A future date of birth fails. + ## Colour | Rule | Parameters | Alias | Message key | diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 7180d59..0d7a938 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -110,6 +110,17 @@ 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', + // Chrono + 'date_interval' => 'The :attribute must be an ISO 8601 duration.', + 'date_interval_positive' => 'The :attribute must be a non-zero ISO 8601 duration.', + 'max_date_difference' => 'The :attribute must be within :hours hours of the reference date.', + 'minimum_age' => 'The :attribute must be a date of birth at least :years years ago.', + 'minute_in' => 'The :attribute must be a time whose minutes are one of: :minutes.', + 'rfc3339' => 'The :attribute must be an RFC 3339 timestamp.', + 'time_of_day' => 'The :attribute must be a valid time of day.', + 'timezone_abbreviation' => 'The :attribute must be a timezone abbreviation.', + 'unix_timestamp' => 'The :attribute must be a Unix timestamp.', + // Encoding 'base64' => 'The :attribute must be a base64-encoded value.', 'base64_image' => 'The :attribute must be a base64-encoded image of an accepted type.', diff --git a/src/Commands/RulesCommand.php b/src/Commands/RulesCommand.php index 24a6588..1034db5 100644 --- a/src/Commands/RulesCommand.php +++ b/src/Commands/RulesCommand.php @@ -67,6 +67,9 @@ private function aliasIndex(): array 'parity' => ['even'], 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], + 'minute_in' => ['0', '15', '30', '45'], + 'max_date_difference' => ['48', '2026-08-24 00:00:00'], + 'minimum_age' => ['18'], 'hash_digest' => ['sha256'], 'max_words' => ['200'], 'min_words' => ['2'], diff --git a/src/Rules/Chrono/DateInterval.php b/src/Rules/Chrono/DateInterval.php new file mode 100644 index 0000000..8b98383 --- /dev/null +++ b/src/Rules/Chrono/DateInterval.php @@ -0,0 +1,48 @@ +passes($value)) { + $key = $this->positive ? 'date_interval_positive' : 'date_interval'; + $fail('laranail-validation::validation.' . $key)->translate(); + } + } + + private function passes(string $value): bool + { + try { + $interval = new \DateInterval($value); + } catch (Exception) { + return false; + } + + if (! $this->positive) { + return true; + } + + return $interval->y + $interval->m + $interval->d + + $interval->h + $interval->i + $interval->s + $interval->f > 0; + } +} diff --git a/src/Rules/Chrono/MaxDateDifference.php b/src/Rules/Chrono/MaxDateDifference.php new file mode 100644 index 0000000..b8aa13c --- /dev/null +++ b/src/Rules/Chrono/MaxDateDifference.php @@ -0,0 +1,80 @@ + */ + private array $data = []; + + public function __construct( + private readonly int $hours, + private readonly DateTimeInterface|string $from, + ) {} + + /** @param array $data */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $reference = $this->reference(); + $subject = is_string($value) ? self::parse($value) : null; + + if ($reference === null || $subject === null + || abs($subject->getTimestamp() - $reference->getTimestamp()) > $this->hours * 3600) { + $fail('laranail-validation::validation.max_date_difference') + ->translate(['hours' => $this->hours]); + } + } + + private function reference(): ?DateTimeImmutable + { + if ($this->from instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($this->from); + } + + if (str_starts_with($this->from, '@')) { + $sibling = Arr::get($this->data, substr($this->from, 1)); + + return is_string($sibling) ? self::parse($sibling) : null; + } + + return self::parse($this->from); + } + + private static function parse(string $value): ?DateTimeImmutable + { + try { + return new DateTimeImmutable($value); + } catch (Exception) { + return null; + } + } +} diff --git a/src/Rules/Chrono/MinimumAge.php b/src/Rules/Chrono/MinimumAge.php new file mode 100644 index 0000000..5df320f --- /dev/null +++ b/src/Rules/Chrono/MinimumAge.php @@ -0,0 +1,56 @@ +passes($value)) { + $fail('laranail-validation::validation.minimum_age')->translate(['years' => $this->years]); + } + } + + private function passes(string $value): bool + { + $configured = config('app.timezone', 'UTC'); + $zone = new DateTimeZone($this->timezone ?? (is_string($configured) ? $configured : 'UTC')); + + try { + $birth = new DateTimeImmutable($value, $zone); + } catch (Exception) { + return false; + } + + $today = now($zone)->toDateTimeImmutable()->setTimezone($zone); + $age = $birth->diff($today); + + return $age->invert === 0 && $age->y >= $this->years; + } +} diff --git a/src/Rules/Chrono/MinuteIn.php b/src/Rules/Chrono/MinuteIn.php new file mode 100644 index 0000000..7bbaa60 --- /dev/null +++ b/src/Rules/Chrono/MinuteIn.php @@ -0,0 +1,60 @@ + $minutes Allowed minute values, each 0–59. + */ + public function __construct(private readonly array $minutes) + { + if ($minutes === []) { + throw new LogicException('MinuteIn needs at least one allowed minute.'); + } + + foreach ($minutes as $minute) { + if ($minute < 0 || $minute > 59) { + throw new LogicException("A minute is 0-59; [{$minute}] is not one."); + } + } + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.minute_in') + ->translate(['minutes' => implode(', ', $this->minutes)]); + } + } + + private function passes(string $value): bool + { + try { + $time = new DateTimeImmutable($value); + } catch (Exception) { + return false; + } + + return in_array((int) $time->format('i'), $this->minutes, true); + } +} diff --git a/src/Rules/Chrono/Rfc3339.php b/src/Rules/Chrono/Rfc3339.php new file mode 100644 index 0000000..0e0feef --- /dev/null +++ b/src/Rules/Chrono/Rfc3339.php @@ -0,0 +1,43 @@ +\d{4})-(?\d{2})-(?\d{2})[Tt]' + . '(?[01]\d|2[0-3]):(?[0-5]\d):(?[0-5]\d|60)(?:\.\d+)?' + . '(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/D'; + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! self::passes($value)) { + $fail('laranail-validation::validation.rfc3339')->translate(); + } + } + + public static function passes(string $value): bool + { + if (preg_match(self::GRAMMAR, $value, $parts) !== 1) { + return false; + } + + return checkdate((int) $parts['m'], (int) $parts['d'], (int) $parts['y']); + } +} diff --git a/src/Rules/Chrono/TimeOfDay.php b/src/Rules/Chrono/TimeOfDay.php new file mode 100644 index 0000000..44a42ae --- /dev/null +++ b/src/Rules/Chrono/TimeOfDay.php @@ -0,0 +1,47 @@ +pattern(), $value) !== 1) { + $fail('laranail-validation::validation.time_of_day')->translate(); + } + } + + private function pattern(): string + { + $sep = preg_quote($this->separator, '/'); + + if ($this->twelveHour) { + return '/^(0?[1-9]|1[0-2])' . $sep . '[0-5]\d(?:' . $sep . '[0-5]\d)? ?[AaPp][Mm]$/D'; + } + + return '/^([01]?\d|2[0-3])' . $sep . '[0-5]\d(?:' . $sep . '[0-5]\d)?$/D'; + } +} diff --git a/src/Rules/Chrono/TimezoneAbbreviation.php b/src/Rules/Chrono/TimezoneAbbreviation.php new file mode 100644 index 0000000..f47a4c8 --- /dev/null +++ b/src/Rules/Chrono/TimezoneAbbreviation.php @@ -0,0 +1,37 @@ +|null */ + private static ?array $abbreviations = null; + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + self::$abbreviations ??= array_fill_keys(array_keys(DateTimeZone::listAbbreviations()), true); + + if (! is_string($value) || ! isset(self::$abbreviations[strtolower($value)])) { + $fail('laranail-validation::validation.timezone_abbreviation')->translate(); + } + } +} diff --git a/src/Rules/Chrono/UnixTimestamp.php b/src/Rules/Chrono/UnixTimestamp.php new file mode 100644 index 0000000..52905e8 --- /dev/null +++ b/src/Rules/Chrono/UnixTimestamp.php @@ -0,0 +1,49 @@ +passes($value)) { + $fail('laranail-validation::validation.unix_timestamp')->translate(); + } + } + + private function passes(mixed $value): bool + { + if (is_int($value)) { + return $this->allowNegative || $value >= 0; + } + + if (! is_string($value)) { + return false; + } + + $pattern = $this->allowNegative ? '/^-?(?:0|[1-9]\d*)$/D' : '/^(?:0|[1-9]\d*)$/D'; + + return preg_match($pattern, $value) === 1 && $value !== '-0'; + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index b55c704..8620978 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -14,6 +14,14 @@ use Simtabi\Laranail\Validation\Rules\Banking\Iban; use Simtabi\Laranail\Validation\Rules\Banking\Isin; use Simtabi\Laranail\Validation\Rules\Banking\Luhn; +use Simtabi\Laranail\Validation\Rules\Chrono\DateInterval; +use Simtabi\Laranail\Validation\Rules\Chrono\MaxDateDifference; +use Simtabi\Laranail\Validation\Rules\Chrono\MinimumAge; +use Simtabi\Laranail\Validation\Rules\Chrono\MinuteIn; +use Simtabi\Laranail\Validation\Rules\Chrono\Rfc3339; +use Simtabi\Laranail\Validation\Rules\Chrono\TimeOfDay; +use Simtabi\Laranail\Validation\Rules\Chrono\TimezoneAbbreviation; +use Simtabi\Laranail\Validation\Rules\Chrono\UnixTimestamp; use Simtabi\Laranail\Validation\Rules\Codes\Asin; use Simtabi\Laranail\Validation\Rules\Codes\Ean; use Simtabi\Laranail\Validation\Rules\Codes\Gtin; @@ -179,6 +187,20 @@ public static function map(): array // to read the country from a sibling field. 'postal_code' => self::postalCode(...), + // Chrono + 'rfc3339' => static fn (): ValidationRule => new Rfc3339(), + // `laranail_time_of_day:true` = 12-hour; second parameter is the + // separator (`laranail_time_of_day:false,.` for `23.59`). + 'time_of_day' => static fn (array $p): ValidationRule => new TimeOfDay(self::flags($p)[0] ?? false, self::str($p, 1, ':')), + 'unix_timestamp' => static fn (array $p): ValidationRule => new UnixTimestamp(...self::flags($p)), + 'date_interval' => static fn (array $p): ValidationRule => new DateInterval(...self::flags($p)), + 'minute_in' => static fn (array $p): ValidationRule => new MinuteIn(self::ints($p)), + // `laranail_max_date_difference:48,@start_at` — hours, then the + // reference (a date, or @field for a sibling). + 'max_date_difference' => static fn (array $p): ValidationRule => new MaxDateDifference((int) self::str($p, 0, '0'), self::str($p, 1)), + 'timezone_abbreviation' => static fn (): ValidationRule => new TimezoneAbbreviation(), + 'minimum_age' => static fn (array $p): ValidationRule => new MinimumAge((int) self::str($p, 0, '0'), self::nullableStr($p, 1)), + // Encoding — Base64Image and DataUri take lists and a byte cap, // which a flat rule string cannot spell honestly (see UNALIASED). 'base64' => static fn (): ValidationRule => new Base64(), diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 1200322..b113e20 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('68 rules') + ->expectsOutputToContain('76 rules') ->assertSuccessful(); }); diff --git a/tests/RuleAliasesTest.php b/tests/RuleAliasesTest.php index c7265f7..91c8e97 100644 --- a/tests/RuleAliasesTest.php +++ b/tests/RuleAliasesTest.php @@ -34,6 +34,9 @@ function sampleParameters(): array 'parity' => ['even'], 'vendor_identifier' => ['aws_region'], 'national_identifier' => ['nl'], + 'minute_in' => ['0', '15', '30', '45'], + 'max_date_difference' => ['48', '2026-08-24 00:00:00'], + 'minimum_age' => ['18'], 'hash_digest' => ['sha256'], 'max_words' => ['200'], 'min_words' => ['2'], diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 8f4c0a6..055d39a 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(68); + ->and(count($classes))->toBe(76); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Chrono/ChronoRulesTest.php b/tests/Rules/Chrono/ChronoRulesTest.php new file mode 100644 index 0000000..f5ee969 --- /dev/null +++ b/tests/Rules/Chrono/ChronoRulesTest.php @@ -0,0 +1,206 @@ +toBeTrue(); +})->with([ + '2020-12-21T23:59:59+00:00', + '2020-12-21T23:59:59Z', + '2020-12-21t23:59:59z', // lowercase t/z are explicitly allowed + '2020-12-21T23:59:59.123Z', + '1937-01-01T12:00:27.87+00:20', + '2024-02-29T00:00:00Z', // leap year + '2016-12-31T23:59:60Z', // a real leap second +]); + +it('rejects timestamps that only look like RFC 3339', function (mixed $value): void { + expect(ruleAccepts(new Rfc3339(), $value))->toBeFalse(); +})->with([ + '2023-02-29T00:00:00Z', // not a leap year + '2020-02-30T00:00:00Z', // no such date + '2020-13-01T00:00:00Z', // month 13 + '2020-12-21T24:00:00Z', // hour 24 + '2020-12-21T23:59:59', // offset is required + '2020-12-21 23:59:59Z', // space instead of T + '2020-12-21T23:59:59+24:00', // offset hour out of range + '20-12-21T23:59:59Z', // two-digit year + 12345, + null, +]); + +// ========================================================================= +// TimeOfDay +// ========================================================================= + +it('accepts 24-hour times, with and without seconds', function (string $value): void { + expect(ruleAccepts(new TimeOfDay(), $value))->toBeTrue(); +})->with(['00:00', '9:30', '23:59', '23:59:59']); + +it('rejects out-of-range 24-hour times and meridiems', function (mixed $value): void { + expect(ruleAccepts(new TimeOfDay(), $value))->toBeFalse(); +})->with(['24:00', '23:60', '23:59:60', '9:05 PM', '930', 930, null]); + +it('accepts 12-hour times only with a meridiem', function (): void { + $twelve = new TimeOfDay(twelveHour: true); + + expect(ruleAccepts($twelve, '9:05 PM'))->toBeTrue() + ->and(ruleAccepts($twelve, '12:00 AM'))->toBeTrue() + ->and(ruleAccepts($twelve, '09:05pm'))->toBeTrue() + ->and(ruleAccepts($twelve, '11:30:15 am'))->toBeTrue() + ->and(ruleAccepts($twelve, '13:00 PM'))->toBeFalse() + ->and(ruleAccepts($twelve, '0:30 AM'))->toBeFalse() + ->and(ruleAccepts($twelve, '9:05'))->toBeFalse(); +}); + +it('honours a custom separator', function (): void { + expect(ruleAccepts(new TimeOfDay(separator: '.'), '23.59'))->toBeTrue() + ->and(ruleAccepts(new TimeOfDay(separator: '.'), '23:59'))->toBeFalse(); +}); + +// ========================================================================= +// UnixTimestamp +// ========================================================================= + +it('accepts canonical unix timestamps', function (mixed $value): void { + expect(ruleAccepts(new UnixTimestamp(), $value))->toBeTrue(); +})->with(['1724457600', [1724457600], '0', [0]]); + +it('rejects floats, leading zeros and pre-epoch values by default', function (mixed $value): void { + expect(ruleAccepts(new UnixTimestamp(), $value))->toBeFalse(); +})->with(['1.5', [1.5], '01', '-1', [-1], '12a', '+5', null]); + +it('accepts pre-epoch timestamps only when asked to', function (): void { + expect(ruleAccepts(new UnixTimestamp(allowNegative: true), '-86400'))->toBeTrue() + ->and(ruleAccepts(new UnixTimestamp(allowNegative: true), -86400))->toBeTrue() + ->and(ruleAccepts(new UnixTimestamp(allowNegative: true), '-0'))->toBeFalse(); +}); + +// ========================================================================= +// DateInterval +// ========================================================================= + +it('accepts ISO 8601 durations', function (string $value): void { + expect(ruleAccepts(new DateInterval(), $value))->toBeTrue(); +})->with(['P1Y', 'PT30M', 'P1DT12H', 'P0Y']); + +it('rejects strings DateInterval cannot parse', function (mixed $value): void { + expect(ruleAccepts(new DateInterval(), $value))->toBeFalse(); +})->with(['1 day', 'p1y', 'P', 'PT', 'P1H', 12, null]); + +it('treats zero as non-positive when positivity is required', function (): void { + expect(ruleAccepts(new DateInterval(positive: true), 'P1D'))->toBeTrue() + ->and(ruleAccepts(new DateInterval(positive: true), 'PT1S'))->toBeTrue() + ->and(ruleAccepts(new DateInterval(positive: true), 'P0Y'))->toBeFalse() + ->and(ruleAccepts(new DateInterval(positive: true), 'PT0S'))->toBeFalse(); +}); + +// ========================================================================= +// MinuteIn +// ========================================================================= + +it('accepts times whose minutes are in the allowed set', function (): void { + $quarters = new MinuteIn([0, 15, 30, 45]); + + expect(ruleAccepts($quarters, '10:15'))->toBeTrue() + ->and(ruleAccepts($quarters, '10:45:59'))->toBeTrue() + ->and(ruleAccepts($quarters, '2026-08-24 10:30:00'))->toBeTrue() + ->and(ruleAccepts($quarters, '10:20'))->toBeFalse() + ->and(ruleAccepts($quarters, 'not a time'))->toBeFalse() + ->and(ruleAccepts($quarters, 1015))->toBeFalse(); +}); + +it('refuses an empty or out-of-range minute set', function (): void { + expect(static fn (): MinuteIn => new MinuteIn([]))->toThrow(LogicException::class) + ->and(static fn (): MinuteIn => new MinuteIn([0, 60]))->toThrow(LogicException::class); +}); + +// ========================================================================= +// MaxDateDifference +// ========================================================================= + +it('bounds the distance from a fixed reference', function (): void { + $rule = new MaxDateDifference(12, '2026-08-24 00:00:00'); + + expect(ruleAccepts($rule, '2026-08-24 10:00:00'))->toBeTrue() + ->and(ruleAccepts($rule, '2026-08-23 14:00:00'))->toBeTrue() // before counts too + ->and(ruleAccepts($rule, '2026-08-24 12:00:00'))->toBeTrue() // exactly 12h + ->and(ruleAccepts($rule, '2026-08-24 12:00:01'))->toBeFalse() + ->and(ruleAccepts($rule, 'not a date'))->toBeFalse() + ->and(ruleAccepts($rule, 42))->toBeFalse(); +}); + +it('reads an @-prefixed reference from a sibling field', function (): void { + $rules = [ + 'start_at' => 'required', + 'end_at' => [new MaxDateDifference(48, '@start_at')], + ]; + + expect(Validator::make( + ['start_at' => '2026-08-24 00:00:00', 'end_at' => '2026-08-25 12:00:00'], + $rules, + )->passes())->toBeTrue() + ->and(Validator::make( + ['start_at' => '2026-08-24 00:00:00', 'end_at' => '2026-08-27 00:00:00'], + $rules, + )->passes())->toBeFalse() + ->and(Validator::make(['end_at' => '2026-08-25 12:00:00'], ['end_at' => [new MaxDateDifference(48, '@start_at')]])->passes())->toBeFalse(); +}); + +// ========================================================================= +// TimezoneAbbreviation +// ========================================================================= + +it('accepts timezone abbreviations in any case', function (string $value): void { + expect(ruleAccepts(new TimezoneAbbreviation(), $value))->toBeTrue(); +})->with(['EST', 'cet', 'EAT', 'utc']); + +it('rejects identifiers and unknown abbreviations', function (mixed $value): void { + // 'Europe/Berlin' is a timezone IDENTIFIER — Laravel's own `timezone` + // rule covers those; this rule is the abbreviation set that rule rejects. + expect(ruleAccepts(new TimezoneAbbreviation(), $value))->toBeFalse(); +})->with(['XYZ', 'Europe/Berlin', 'GMT+2', 123, null]); + +// ========================================================================= +// MinimumAge +// ========================================================================= + +it('measures age in completed years', function (): void { + Carbon::setTestNow('2026-08-24 12:00:00'); + + expect(ruleAccepts(new MinimumAge(18), '2000-01-01'))->toBeTrue() + ->and(ruleAccepts(new MinimumAge(18), '2008-08-24'))->toBeTrue() // 18 today + ->and(ruleAccepts(new MinimumAge(18), '2008-08-25'))->toBeFalse() // 18 tomorrow + ->and(ruleAccepts(new MinimumAge(18), '2010-01-01'))->toBeFalse() + ->and(ruleAccepts(new MinimumAge(18), '2030-01-01'))->toBeFalse() // the future + ->and(ruleAccepts(new MinimumAge(18), 'not a date'))->toBeFalse() + ->and(ruleAccepts(new MinimumAge(18), 18))->toBeFalse(); +}); + +it('answers in the timezone it is told to', function (): void { + // 20:00 UTC on the 23rd is already the 24th in Auckland: the same + // person is 17 in London and 18 at home. + Carbon::setTestNow('2026-08-23 20:00:00'); + + expect(ruleAccepts(new MinimumAge(18, timezone: 'UTC'), '2008-08-24'))->toBeFalse() + ->and(ruleAccepts(new MinimumAge(18, timezone: 'Pacific/Auckland'), '2008-08-24'))->toBeTrue(); +}); From bc83abe52469623ac3ec1204160a8105d3b07207 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 05:56:10 -0400 Subject: [PATCH 08/11] Add the Payment family over a brand catalogue that is data, not classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardNumber identifies the brand by IIN range through the new CardBrandCatalogue contract (bundled default: the fourteen legacy brands), checks length against what THAT brand issues, and verifies Luhn where the brand carries it — UnionPay does not, universally, so that is a brand property rather than an assumption that declines real cards. Two legacy range bugs die in the port: Discover's 622126-622925 block was excluded from UnionPay as two POINTS instead of an interval, and Troy's inverted lookahead matched every 9-card except a Troy. The typed-exception ergonomics survive as distinct message keys — wrong length, failed checksum and unknown range send the cardholder to different corrections. CardCvc narrows to the sibling number's brand via numberField:, and deliberately falls back to 3-or-4 when that sibling is unrecognisable — a CVC error for a problem in the other field sends the user to the wrong box. CardExpiry reads the spellings embossing uses, is valid through the end of the stated month in a named timezone, and rejects the implausibly distant typo. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 30 ++++ resources/lang/en/validation.php | 8 + src/Contracts/Payment/CardBrandCatalogue.php | 27 +++ src/Rules/Payment/CardCvc.php | 75 ++++++++ src/Rules/Payment/CardExpiry.php | 66 ++++++++ src/Rules/Payment/CardNumber.php | 123 ++++++++++++++ .../Payment/BundledCardBrandCatalogue.php | 76 +++++++++ src/Support/Payment/CardBrand.php | 71 ++++++++ src/Support/RuleAliases.php | 10 ++ src/ValidationServiceProvider.php | 3 + tests/ConsoleCommandsTest.php | 2 +- tests/RuleRegistrarTest.php | 2 +- tests/Rules/Payment/PaymentRulesTest.php | 160 ++++++++++++++++++ 14 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 src/Contracts/Payment/CardBrandCatalogue.php create mode 100644 src/Rules/Payment/CardCvc.php create mode 100644 src/Rules/Payment/CardExpiry.php create mode 100644 src/Rules/Payment/CardNumber.php create mode 100644 src/Support/Payment/BundledCardBrandCatalogue.php create mode 100644 src/Support/Payment/CardBrand.php create mode 100644 tests/Rules/Payment/PaymentRulesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcf5c0..8bb9543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 76 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 79 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 153b682..f9ec96d 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -556,6 +556,36 @@ It is a filter, not a moderation system. Anyone determined to get a word past it point is to catch the careless case without insulting the innocent one. There is no string alias — a rule string cannot carry a word list. +## Payment + +| Rule | Parameters | Alias | Message key | +|---|---|---|---| +| `CardNumber` | `?array $brands = null` | `card_number` | `card_number` / `card_number_brand` / `card_number_length` / `card_number_checksum` | +| `CardCvc` | `?string $numberField = null` | `card_cvc` | `card_cvc` | +| `CardExpiry` | `?string $timezone = null, int $maxYearsAhead = 20` | `card_expiry` | `card_expiry` | + +- **`CardNumber`** — brand identified by IIN range, length checked against what *that* brand + issues, Luhn verified where the brand carries it (UnionPay does not, so it is a brand + property, not an assumption). Spaces and hyphens are stripped. `brands: ['visa', + 'mastercard']` (`laranail_card_number:visa,mastercard`) is the "we only take" case. The + legacy engine's typed exceptions survive as distinct message keys — a wrong length and a + failed checksum send the cardholder to different corrections. +- **`CardCvc`** — 3 or 4 digits alone; `numberField: 'card_number'` narrows to the sibling + number's brand (Visa 3, Amex 3 or 4). An unrecognisable sibling falls back to 3-or-4 rather + than failing: the number field's own rule reports the bad number. +- **`CardExpiry`** — `08/26`, `8/26`, `12/2027`, `08-26`, `2027-03`; valid through the end of + the stated month in the named timezone. `$maxYearsAhead` rejects the implausibly distant + typo (`08/47`). + +The brand data is the `Contracts\Payment\CardBrandCatalogue` contract with a bundled +14-brand default (the legacy engine's set, its range bugs fixed — see +`Support\Payment\BundledCardBrandCatalogue`). Bind your own to add a store card or trim to +what the processor accepts. + +> **Validity is not authorisation, and a PAN is cardholder data.** These rules prove shape; +> they say nothing about the account, and validating a number does not license storing or +> logging it. No failure message echoes the value. + ## Postal | Rule | Parameters | Alias | Message key | diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 0d7a938..aa45478 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -110,6 +110,14 @@ 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', + // Payment + 'card_cvc' => 'The :attribute must be a valid security code.', + 'card_expiry' => 'The :attribute must be a valid expiry date that has not passed.', + 'card_number' => 'The :attribute is not a recognised card number.', + 'card_number_brand' => 'The :attribute must be one of these card brands: :brands.', + 'card_number_checksum' => 'The :attribute fails the card number check digit.', + 'card_number_length' => 'The :attribute must be :lengths digits for :brand.', + // Chrono 'date_interval' => 'The :attribute must be an ISO 8601 duration.', 'date_interval_positive' => 'The :attribute must be a non-zero ISO 8601 duration.', diff --git a/src/Contracts/Payment/CardBrandCatalogue.php b/src/Contracts/Payment/CardBrandCatalogue.php new file mode 100644 index 0000000..1f33e76 --- /dev/null +++ b/src/Contracts/Payment/CardBrandCatalogue.php @@ -0,0 +1,27 @@ + */ + public function brands(): array; + + /** + * The brand a (digits-only) number belongs to — the most specific + * range match wins — or null when no range claims it. + */ + public function identify(string $number): ?CardBrand; +} diff --git a/src/Rules/Payment/CardCvc.php b/src/Rules/Payment/CardCvc.php new file mode 100644 index 0000000..e796fb6 --- /dev/null +++ b/src/Rules/Payment/CardCvc.php @@ -0,0 +1,75 @@ + */ + private array $data = []; + + public function __construct( + private readonly ?string $numberField = null, + private ?CardBrandCatalogue $catalogue = null, + ) {} + + /** @param array $data */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! ctype_digit($value) + || ! in_array(strlen($value), $this->allowedLengths(), true)) { + $fail('laranail-validation::validation.card_cvc')->translate(); + } + } + + /** @return list */ + private function allowedLengths(): array + { + if ($this->numberField !== null) { + $number = Arr::get($this->data, $this->numberField); + + if (is_string($number)) { + $brand = $this->catalogue()->identify(str_replace([' ', '-'], '', $number)); + + if ($brand !== null) { + return $brand->cvcLengths; + } + } + } + + return [3, 4]; + } + + private function catalogue(): CardBrandCatalogue + { + return $this->catalogue ??= resolve(CardBrandCatalogue::class); + } +} diff --git a/src/Rules/Payment/CardExpiry.php b/src/Rules/Payment/CardExpiry.php new file mode 100644 index 0000000..c2cf3a0 --- /dev/null +++ b/src/Rules/Payment/CardExpiry.php @@ -0,0 +1,66 @@ +0?[1-9]|1[0-2])[\/-](?\d{2}|\d{4})|(?\d{4})-(?0[1-9]|1[0-2]))$/D'; + + public function __construct( + private readonly ?string $timezone = null, + private readonly int $maxYearsAhead = 20, + ) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.card_expiry')->translate(); + } + } + + private function passes(string $value): bool + { + if (preg_match(self::SHAPE, $value, $parts) !== 1) { + return false; + } + + $month = (int) ($parts['m1'] !== '' ? $parts['m1'] : $parts['m2']); + $year = $parts['m1'] !== '' ? $parts['y1'] : $parts['y2']; + $year = strlen($year) === 2 ? 2000 + (int) $year : (int) $year; + + $configured = config('app.timezone', 'UTC'); + $zone = new DateTimeZone($this->timezone ?? (is_string($configured) ? $configured : 'UTC')); + $today = now($zone)->toDateTimeImmutable()->setTimezone($zone); + + $currentYear = (int) $today->format('Y'); + $currentMonth = (int) $today->format('n'); + + if ($year < $currentYear || ($year === $currentYear && $month < $currentMonth)) { + return false; + } + + return $year - $currentYear < $this->maxYearsAhead; + } +} diff --git a/src/Rules/Payment/CardNumber.php b/src/Rules/Payment/CardNumber.php new file mode 100644 index 0000000..7c4fff3 --- /dev/null +++ b/src/Rules/Payment/CardNumber.php @@ -0,0 +1,123 @@ +|null $brands Accepted brand slugs; null accepts the whole catalogue. + */ + public function __construct( + private readonly ?array $brands = null, + private ?CardBrandCatalogue $catalogue = null, + ) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value)) { + $this->fail($fail, 'card_number'); + + return; + } + + $digits = str_replace([' ', '-'], '', $value); + + if ($digits === '' || ! ctype_digit($digits)) { + $this->fail($fail, 'card_number'); + + return; + } + + $brand = $this->catalogue()->identify($digits); + + if ($brand === null) { + $this->fail($fail, 'card_number'); + + return; + } + + if ($this->brands !== null && ! in_array($brand->name, $this->brands, true)) { + $this->fail($fail, 'card_number_brand', ['brands' => implode(', ', $this->brands)]); + + return; + } + + if (! in_array(strlen($digits), $brand->lengths, true)) { + $this->fail($fail, 'card_number_length', [ + 'lengths' => implode(' or ', $brand->lengths), + 'brand' => $brand->displayName, + ]); + + return; + } + + if ($brand->luhn && ! self::luhnPasses($digits)) { + $this->fail($fail, 'card_number_checksum'); + } + } + + public static function luhnPasses(string $digits): bool + { + $sum = 0; + $double = false; + + for ($i = strlen($digits) - 1; $i >= 0; --$i) { + $digit = (int) $digits[$i]; + + if ($double) { + $digit *= 2; + + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + $double = ! $double; + } + + return $sum % 10 === 0; + } + + private function catalogue(): CardBrandCatalogue + { + return $this->catalogue ??= resolve(CardBrandCatalogue::class); + } + + /** + * @param Closure(string): PotentiallyTranslatedString $fail + * @param array $parameters + */ + private function fail(Closure $fail, string $key, array $parameters = []): void + { + $fail('laranail-validation::validation.' . $key)->translate($parameters); + } +} diff --git a/src/Support/Payment/BundledCardBrandCatalogue.php b/src/Support/Payment/BundledCardBrandCatalogue.php new file mode 100644 index 0000000..4b744ae --- /dev/null +++ b/src/Support/Payment/BundledCardBrandCatalogue.php @@ -0,0 +1,76 @@ +|null */ + private ?array $brands = null; + + public function brands(): array + { + return $this->brands ??= [ + new CardBrand('visa', 'Visa', [['4', '4']], [13, 16, 19], [3]), + new CardBrand('visaelectron', 'Visa Electron', [ + ['4026', '4026'], ['417500', '417500'], ['4405', '4405'], + ['4508', '4508'], ['4844', '4844'], ['4913', '4913'], ['4917', '4917'], + ], [16], [3]), + new CardBrand('mastercard', 'Mastercard', [['51', '55'], ['2221', '2720']], [16], [3]), + new CardBrand('amex', 'American Express', [['34', '34'], ['37', '37']], [15], [3, 4]), + new CardBrand('discover', 'Discover', [ + ['6011', '6011'], ['622126', '622925'], ['644', '649'], ['65', '65'], + ], [16, 19], [3]), + new CardBrand('unionpay', 'UnionPay', [['62', '62']], [16, 17, 18, 19], [3], luhn: false), + new CardBrand('dinersclub', 'Diners Club', [ + ['300', '305'], ['36', '36'], ['38', '39'], + ], [14, 16, 19], [3]), + new CardBrand('jcb', 'JCB', [['3528', '3589']], [16, 17, 18, 19], [3]), + new CardBrand('maestro', 'Maestro', [ + ['5018', '5018'], ['502', '503'], ['505', '505'], + ['56', '58'], ['61', '61'], ['639', '639'], ['67', '69'], + ], [12, 13, 14, 15, 16, 17, 18, 19], [3]), + new CardBrand('mir', 'Mir', [['2200', '2204']], [16, 17, 18, 19], [3]), + new CardBrand('troy', 'Troy', [['9792', '9792']], [16], [3]), + new CardBrand('dankort', 'Dankort', [['5019', '5019']], [16], [3]), + new CardBrand('hipercard', 'Hipercard', [ + ['606282', '606282'], ['3841', '3841'], + ], [13, 16, 19], [3]), + new CardBrand('forbrugsforeningen', 'Forbrugsforeningen', [['600722', '600722']], [16], [3]), + ]; + } + + public function identify(string $number): ?CardBrand + { + $winner = null; + $bestWidth = 0; + + foreach ($this->brands() as $brand) { + $width = $brand->matchWidth($number); + + if ($width !== null && $width > $bestWidth) { + $winner = $brand; + $bestWidth = $width; + } + } + + return $winner; + } +} diff --git a/src/Support/Payment/CardBrand.php b/src/Support/Payment/CardBrand.php new file mode 100644 index 0000000..7040416 --- /dev/null +++ b/src/Support/Payment/CardBrand.php @@ -0,0 +1,71 @@ + $ranges Inclusive prefix intervals, equal-width bounds. + * @param list $lengths + * @param list $cvcLengths + */ + public function __construct( + public string $name, + public string $displayName, + public array $ranges, + public array $lengths, + public array $cvcLengths, + public bool $luhn = true, + ) { + foreach ($ranges as [$low, $high]) { + if (strlen($low) !== strlen($high)) { + throw new LogicException( + "Range bounds for [{$name}] must be equal width; [{$low}]-[{$high}] are not.", + ); + } + } + } + + /** Whether the (digits-only) number starts inside one of the brand's ranges. */ + public function matches(string $number): bool + { + return $this->matchWidth($number) !== null; + } + + /** + * The width of the most specific matching range — the tie-breaker when + * two brands claim a prefix (622126 is Discover by a 6-digit range and + * UnionPay by a 2-digit one; the 6 wins). + */ + public function matchWidth(string $number): ?int + { + $best = null; + + foreach ($this->ranges as [$low, $high]) { + $width = strlen($low); + $prefix = substr($number, 0, $width); + + if (strlen($prefix) === $width && $prefix >= $low && $prefix <= $high) { + $best = max($best ?? 0, $width); + } + } + + return $best; + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 8620978..0a78dcb 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -68,6 +68,9 @@ use Simtabi\Laranail\Validation\Rules\Network\DeliverableEmail; use Simtabi\Laranail\Validation\Rules\Numbers\MonetaryAmount; use Simtabi\Laranail\Validation\Rules\Numbers\Parity; +use Simtabi\Laranail\Validation\Rules\Payment\CardCvc; +use Simtabi\Laranail\Validation\Rules\Payment\CardExpiry; +use Simtabi\Laranail\Validation\Rules\Payment\CardNumber; use Simtabi\Laranail\Validation\Rules\Postal\PostalCode; use Simtabi\Laranail\Validation\Rules\Profanity\NoProfanity; use Simtabi\Laranail\Validation\Rules\Structure\Delimited; @@ -187,6 +190,13 @@ public static function map(): array // to read the country from a sibling field. 'postal_code' => self::postalCode(...), + // Payment — `laranail_card_number:visa,mastercard` restricts + // brands; `laranail_card_cvc:card_number` names the sibling + // number field; `laranail_card_expiry:Europe/Berlin` the zone. + 'card_number' => static fn (array $p): ValidationRule => new CardNumber(self::strings($p) === [] ? null : self::strings($p)), + 'card_cvc' => static fn (array $p): ValidationRule => new CardCvc(self::nullableStr($p, 0)), + 'card_expiry' => static fn (array $p): ValidationRule => new CardExpiry(self::nullableStr($p, 0)), + // Chrono 'rfc3339' => static fn (): ValidationRule => new Rfc3339(), // `laranail_time_of_day:true` = 12-hour; second parameter is the diff --git a/src/ValidationServiceProvider.php b/src/ValidationServiceProvider.php index 82fabd0..8c5f1f1 100644 --- a/src/ValidationServiceProvider.php +++ b/src/ValidationServiceProvider.php @@ -18,11 +18,13 @@ use Simtabi\Laranail\Validation\Contracts\I18n\CountryDataset; use Simtabi\Laranail\Validation\Contracts\I18n\CurrencyDataset; use Simtabi\Laranail\Validation\Contracts\I18n\LanguageDataset; +use Simtabi\Laranail\Validation\Contracts\Payment\CardBrandCatalogue; use Simtabi\Laranail\Validation\Support\Email\BundledDisposableDomainList; use Simtabi\Laranail\Validation\Support\Email\BundledRoleAccountList; use Simtabi\Laranail\Validation\Support\I18n\BundledCountryDataset; use Simtabi\Laranail\Validation\Support\I18n\BundledCurrencyDataset; use Simtabi\Laranail\Validation\Support\I18n\BundledLanguageDataset; +use Simtabi\Laranail\Validation\Support\Payment\BundledCardBrandCatalogue; use Simtabi\Laranail\Validation\Support\RuleAliases; use Simtabi\Laranail\Validation\Support\RuleRegistrar; use Stringable; @@ -103,6 +105,7 @@ private function bindI18nDatasetFallbacks(): void $this->app->singletonIf(CountryDataset::class, BundledCountryDataset::class); $this->app->singletonIf(CurrencyDataset::class, BundledCurrencyDataset::class); $this->app->singletonIf(LanguageDataset::class, BundledLanguageDataset::class); + $this->app->singletonIf(CardBrandCatalogue::class, BundledCardBrandCatalogue::class); } public function bootingPackage(): void diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index b113e20..42c2dab 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('76 rules') + ->expectsOutputToContain('79 rules') ->assertSuccessful(); }); diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 055d39a..e7b33b5 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(76); + ->and(count($classes))->toBe(79); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Payment/PaymentRulesTest.php b/tests/Rules/Payment/PaymentRulesTest.php new file mode 100644 index 0000000..39fe5c3 --- /dev/null +++ b/tests/Rules/Payment/PaymentRulesTest.php @@ -0,0 +1,160 @@ +toBeTrue() + ->and(resolve(CardBrandCatalogue::class)->identify(preg_replace('/\D/', '', $number) ?? '')?->name) + ->toBe($brand); +})->with([ + ['4242424242424242', 'visa'], + ['4222222222222', 'visa'], // the 13-digit Visa length + ['5555555555554444', 'mastercard'], + ['2223003122003222', 'mastercard'], // the 2-series range + ['378282246310005', 'amex'], + ['6011111111111117', 'discover'], + ['6221260000000000', 'discover'], // low end of 622126-622925 + ['6212340000000001', 'unionpay'], // 62, outside the Discover range + ['30569309025904', 'dinersclub'], + ['3530111333300000', 'jcb'], + ['2200000000000004', 'mir'], + ['9792000000000003', 'troy'], + ['5019717010103742', 'dankort'], + ['6759649826438453', 'maestro'], + ['6062826786276634', 'hipercard'], + ['6007220000000004', 'forbrugsforeningen'], + ['4026000000000002', 'visaelectron'], + ['4242 4242 4242 4242', 'visa'], // spaces and hyphens are stripped + ['4242-4242-4242-4242', 'visa'], +]); + +it('rejects failing checksums, wrong lengths and unknown ranges', function (mixed $value): void { + expect(ruleAccepts(new CardNumber(), $value))->toBeFalse(); +})->with([ + '4242424242424241', // Luhn fails + '424242424242424', // 15 digits is not a Visa length + '1234567890123452', // Luhn-valid but no brand ranges start with 1 + '9791000000000004', // 9791 is not Troy (the legacy pattern matched ALL of 9xx) + 'not a card', + 4242424242424242, // numbers arrive as strings; an int is a coding error + null, +]); + +it('can be restricted to named brands', function (): void { + $visaOnly = new CardNumber(brands: ['visa']); + + expect(ruleAccepts($visaOnly, '4242424242424242'))->toBeTrue() + ->and(ruleAccepts($visaOnly, '5555555555554444'))->toBeFalse(); +}); + +it('separates the failure messages a cardholder can act on', function (): void { + // The legacy engine's typed exceptions, folded into distinct message + // keys: a wrong length and a failed checksum send the user to + // different corrections. + $messages = static fn (string $number): string => Validator::make( + ['card' => $number], + ['card' => new CardNumber()], + )->errors()->first('card'); + + expect($messages('424242424242424'))->toContain('digits') + ->and($messages('4242424242424241'))->toContain('check') + ->and($messages('1234567890123452'))->toContain('recognised'); +}); + +it('honours a bound catalogue', function (): void { + $this->app->instance(CardBrandCatalogue::class, new class implements CardBrandCatalogue { + public function brands(): array + { + return [$this->store()]; + } + + public function identify(string $number): ?CardBrand + { + return str_starts_with($number, '999') ? $this->store() : null; + } + + private function store(): CardBrand + { + return new CardBrand('storecard', 'Store Card', [['999', '999']], [10], [3], luhn: false); + } + }); + + expect(ruleAccepts(new CardNumber(), '9990000000'))->toBeTrue() + ->and(ruleAccepts(new CardNumber(), '4242424242424242'))->toBeFalse(); +}); + +// ========================================================================= +// CardCvc +// ========================================================================= + +it('accepts 3 or 4 digits when no brand context is given', function (): void { + expect(ruleAccepts(new CardCvc(), '123'))->toBeTrue() + ->and(ruleAccepts(new CardCvc(), '1234'))->toBeTrue() + ->and(ruleAccepts(new CardCvc(), '12'))->toBeFalse() + ->and(ruleAccepts(new CardCvc(), '12345'))->toBeFalse() + ->and(ruleAccepts(new CardCvc(), '12a'))->toBeFalse() + ->and(ruleAccepts(new CardCvc(), 123))->toBeFalse(); +}); + +it('narrows to the brand of a sibling card number', function (): void { + $data = static fn (string $number, string $cvc): bool => Validator::make( + ['number' => $number, 'cvc' => $cvc], + ['cvc' => new CardCvc(numberField: 'number')], + )->passes(); + + expect($data('4242424242424242', '123'))->toBeTrue() // Visa: 3 + ->and($data('4242424242424242', '1234'))->toBeFalse() + ->and($data('378282246310005', '1234'))->toBeTrue() // Amex: 3 or 4 + ->and($data('378282246310005', '123'))->toBeTrue() + // An unrecognised number falls back to 3-or-4: the CVC field must + // not fail because the NUMBER field is wrong — that rule says so. + ->and($data('1234567890123452', '123'))->toBeTrue(); +}); + +// ========================================================================= +// CardExpiry +// ========================================================================= + +it('accepts current and future expiries in the common spellings', function (string $value): void { + Carbon::setTestNow('2026-08-24 12:00:00'); + + expect(ruleAccepts(new CardExpiry(), $value))->toBeTrue(); +})->with(['08/26', '09/26', '12/2027', '08-26', '11-2030', '2027-03', '8/27']); + +it('rejects past, malformed and implausibly distant expiries', function (mixed $value): void { + Carbon::setTestNow('2026-08-24 12:00:00'); + + expect(ruleAccepts(new CardExpiry(), $value))->toBeFalse(); +})->with([ + '07/26', // last month + '12/25', // last year + '13/27', // month 13 + '00/27', + '08/47', // beyond the plausible-typo horizon (20 years) + '0826', + 'never', + 826, + null, +]); + +it('treats the current month as valid until it ends, in the given timezone', function (): void { + // 23:30 UTC on Aug 31 is already September in Nairobi. + Carbon::setTestNow('2026-08-31 23:30:00'); + + expect(ruleAccepts(new CardExpiry(timezone: 'UTC'), '08/26'))->toBeTrue() + ->and(ruleAccepts(new CardExpiry(timezone: 'Africa/Nairobi'), '08/26'))->toBeFalse(); +}); From 72969a2ad82e967f8f99b396e041baba9b4b76b6 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 05:59:53 -0400 Subject: [PATCH 09/11] Add VatNumber with computed national checksums and the VN CCCD scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy family had one pattern table plus nine per-country traits; this is one country-aware rule where the checksum countries are COMPUTED — NL's 11-proef (or the 2020 sole-proprietor mod-97 form), BE's mod-97 complement, DE's ISO 7064 MOD 11,10, IT's Luhn variant, SE's Luhn+01, EL's power-of-two sum, LU's mod-89 and FR's numeric key — because a transposed pair failing arithmetic is the whole point of these digits, and every reject-vector in the test is a valid pattern with one digit off. Letter-scheme countries (ES, GB, FR alpha keys) are format-only and say so. Punctuation is stripped first; BE 0423.456.765 is how invoices write it. NationalIdentifier gains the VN CCCD scheme the parity matrix flagged missing: 12 digits, province 001-096, and — stated, not faked — no checksum exists. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 11 ++ resources/lang/en/validation.php | 1 + src/Rules/Fiscal/NationalIdentifier.php | 15 ++ src/Rules/Fiscal/VatNumber.php | 221 ++++++++++++++++++++++++ src/Support/RuleAliases.php | 3 + tests/ConsoleCommandsTest.php | 2 +- tests/RuleRegistrarTest.php | 2 +- tests/Rules/Fiscal/VatNumberTest.php | 90 ++++++++++ 9 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 src/Rules/Fiscal/VatNumber.php create mode 100644 tests/Rules/Fiscal/VatNumberTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb9543..3ee873b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 79 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 80 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index f9ec96d..bb7d2a3 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -285,6 +285,7 @@ Domain and mailbox rules. Deliverability needs DNS, so it lives in the Network t | Rule | Parameters | Alias | Message key | |---|---|---|---| | `NationalIdentifier` | `string $country` | `national_identifier` | `national_identifier` | +| `VatNumber` | `?array $countries = null` | `vat_number` | `vat_number` | **`NationalIdentifier`** validates a national identification number in a particular country's scheme. One rule parameterised by country, because the field always means "this person's @@ -297,6 +298,7 @@ national id" and which scheme applies is a property of the country. | `FR` | NIR / numéro de sécurité sociale | mod-97 key | | `US` | Social Security Number | format and unissued ranges — **no checksum exists** | | `GB` | National Insurance number | format and reserved prefixes — **no checksum exists** | +| `VN` | CCCD citizen identification | structure (province 001-096, 12 digits) — **no checksum exists** | Where a scheme has a checksum it is computed, not pattern-matched: the entire value of these numbers is that a transposed pair fails arithmetic instead of sailing through a regex. Where a @@ -317,6 +319,15 @@ Two details these get wrong when written quickly: None of these can tell you a number was **issued** — only that it is well-formed. That needs the issuing authority. +**`VatNumber`** validates an EU-style VAT identifier by its country prefix: the national +format, and the national checksum where one is defined (NL 11-proef or the 2020 mod-97 form, +BE, DE, IT, SE, EL, LU, and FR's numeric key — each computed, not pattern-matched). ES, GB and +the other letter-scheme countries are format-only, and the class docblock says exactly which. +Spaces, dots and hyphens are stripped (`BE 0423.456.765` is how invoices write it); +`laranail_vat_number:NL,BE` restricts countries. Format validity is not registration — only +VIES or the national registry can say a number is issued, and this pure-tier rule deliberately +does not ask the network. + ## Geo | Rule | Parameters | Alias | Message key | diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index aa45478..0ab9a8e 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -208,6 +208,7 @@ 'vendor_identifier' => 'The :attribute must be a valid :vendor identifier.', // Fiscal + 'vat_number' => 'The :attribute must be a valid VAT number.', 'national_identifier' => 'The :attribute must be a valid :country national identification number.', // Profanity diff --git a/src/Rules/Fiscal/NationalIdentifier.php b/src/Rules/Fiscal/NationalIdentifier.php index 0632026..8954e5d 100644 --- a/src/Rules/Fiscal/NationalIdentifier.php +++ b/src/Rules/Fiscal/NationalIdentifier.php @@ -44,6 +44,9 @@ /** France — NIR / numéro de sécurité sociale, mod-97 key. */ public const string FR = 'fr'; + /** Vietnam — CCCD citizen identification. Structure only: NO checksum exists. */ + public const string VN = 'vn'; + public function __construct(private string $country) {} public function validate(string $attribute, mixed $value, Closure $fail): void @@ -66,6 +69,7 @@ public static function passes(mixed $value, string $country): bool self::US => self::americanSsn($value), self::GB => self::britishNino($value), self::FR => self::frenchNir($value), + self::VN => self::vietnameseCccd($value), default => false, }; } @@ -198,6 +202,17 @@ private static function frenchNir(string $value): bool return (int) $key === 97 - ((int) $number % 97); } + /** + * Twelve digits: a 001-096 province code, a gender/century digit, the + * two-digit birth year, and six serial digits. The scheme publishes no + * check digit, so structure is all that CAN be verified — stated here + * rather than faked with an invented checksum. + */ + private static function vietnameseCccd(string $value): bool + { + return preg_match('/^0(?:0[1-9]|[1-8]\d|9[0-6])\d{9}$/D', $value) === 1; + } + private static function digits(string $value): string { return preg_replace('/\D/', '', $value) ?? ''; diff --git a/src/Rules/Fiscal/VatNumber.php b/src/Rules/Fiscal/VatNumber.php new file mode 100644 index 0000000..6070adf --- /dev/null +++ b/src/Rules/Fiscal/VatNumber.php @@ -0,0 +1,221 @@ + 'U[A-Z0-9]{8}', + 'BE' => '[01]\d{9}', + 'BG' => '\d{9,10}', + 'CY' => '\d{8}[A-Z]', + 'CZ' => '\d{8,10}', + 'DE' => '\d{9}', + 'DK' => '\d{8}', + 'EE' => '\d{9}', + 'EL' => '\d{9}', + 'ES' => '[A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8}', + 'FI' => '\d{8}', + 'FR' => '[A-HJ-NP-Z0-9]{2}\d{9}', + 'GB' => '\d{9}|\d{12}|(?:GD|HA)\d{3}', + 'HR' => '\d{11}', + 'HU' => '\d{8}', + 'IE' => '[A-Z0-9]{8}|[A-Z0-9]{9}', + 'IT' => '\d{11}', + 'LT' => '\d{9}|\d{12}', + 'LU' => '\d{8}', + 'LV' => '\d{11}', + 'MT' => '\d{8}', + 'NL' => '\d{9}B\d{2}', + 'PL' => '\d{10}', + 'PT' => '\d{9}', + 'RO' => '\d{2,10}', + 'SE' => '\d{12}', + 'SI' => '\d{8}', + 'SK' => '\d{10}', + ]; + + /** + * @param list|null $countries Accepted prefixes; null accepts every known one. + */ + public function __construct(private readonly ?array $countries = null) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.vat_number')->translate(); + } + } + + private function passes(string $value): bool + { + $normalised = strtoupper((string) preg_replace('/[\s.\-]+/', '', $value)); + $country = substr($normalised, 0, 2); + $tail = substr($normalised, 2); + + if (! isset(self::PATTERNS[$country])) { + return false; + } + + if ($this->countries !== null + && ! in_array($country, array_map(strtoupper(...), $this->countries), true)) { + return false; + } + + if (preg_match('/^(?:' . self::PATTERNS[$country] . ')$/D', $tail) !== 1) { + return false; + } + + return match ($country) { + 'NL' => self::dutch($normalised, $tail), + 'BE' => (int) substr($tail, 8, 2) === 97 - ((int) substr($tail, 0, 8) % 97), + 'DE' => self::germanMod11x10($tail), + 'IT' => self::italianLuhn($tail), + 'SE' => str_ends_with($tail, '01') && self::luhnPasses(substr($tail, 0, 10)), + 'EL' => self::greekPowerSum($tail), + 'LU' => (int) substr($tail, 6, 2) === (int) substr($tail, 0, 6) % 89, + 'FR' => self::frenchKey($tail), + default => true, + }; + } + + /** The 11-proef, or the 2020 sole-proprietor mod-97 form. */ + private static function dutch(string $full, string $tail): bool + { + $sum = 0; + + for ($i = 0; $i < 8; ++$i) { + $sum += ((int) $tail[$i]) * (9 - $i); + } + + if ($sum % 11 === (int) $tail[8]) { + return true; + } + + // Mod-97 over the whole identifier with letters as values (A=10…). + $converted = ''; + + foreach (str_split($full) as $character) { + $converted .= ctype_digit($character) ? $character : (string) (ord($character) - 55); + } + + $remainder = 0; + + foreach (str_split($converted) as $digit) { + $remainder = ($remainder * 10 + (int) $digit) % 97; + } + + return $remainder === 1; + } + + /** ISO 7064 MOD 11,10 over the nine digits. */ + private static function germanMod11x10(string $digits): bool + { + $product = 10; + + for ($i = 0; $i < 8; ++$i) { + $sum = (((int) $digits[$i]) + $product) % 10; + + if ($sum === 0) { + $sum = 10; + } + + $product = (2 * $sum) % 11; + } + + $check = 11 - $product; + + return ($check === 10 ? 0 : $check) === (int) $digits[8]; + } + + /** Odd positions as-is, even doubled with the 9-fold — total mod 10 is zero. */ + private static function italianLuhn(string $digits): bool + { + $sum = 0; + + foreach (str_split($digits) as $index => $digit) { + $digit = (int) $digit; + + if (($index + 1) % 2 === 0) { + $digit *= 2; + + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + } + + return $sum % 10 === 0; + } + + private static function greekPowerSum(string $digits): bool + { + $sum = 0; + + for ($i = 0; $i < 8; ++$i) { + $sum += ((int) $digits[$i]) * (2 ** (8 - $i)); + } + + return ($sum % 11) % 10 === (int) $digits[8]; + } + + /** The numeric control key; keys containing letters pass on format alone. */ + private static function frenchKey(string $tail): bool + { + $key = substr($tail, 0, 2); + + if (! ctype_digit($key)) { + return true; + } + + return (int) $key === (12 + 3 * ((int) substr($tail, 2) % 97)) % 97; + } + + private static function luhnPasses(string $digits): bool + { + $sum = 0; + $double = false; + + for ($i = strlen($digits) - 1; $i >= 0; --$i) { + $digit = (int) $digits[$i]; + + if ($double) { + $digit *= 2; + + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + $double = ! $double; + } + + return $sum % 10 === 0; + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 0a78dcb..39ddfe5 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -43,6 +43,7 @@ use Simtabi\Laranail\Validation\Rules\Encoding\Base64Image; use Simtabi\Laranail\Validation\Rules\Encoding\DataUri; use Simtabi\Laranail\Validation\Rules\Fiscal\NationalIdentifier; +use Simtabi\Laranail\Validation\Rules\Fiscal\VatNumber; use Simtabi\Laranail\Validation\Rules\Geo\CaProvince; use Simtabi\Laranail\Validation\Rules\Geo\Latitude; use Simtabi\Laranail\Validation\Rules\Geo\LatLng; @@ -253,6 +254,8 @@ public static function map(): array // Fiscal 'national_identifier' => static fn (array $p): ValidationRule => new NationalIdentifier(self::str($p, 0)), + // `laranail_vat_number:NL,BE` restricts the accepted countries. + 'vat_number' => static fn (array $p): ValidationRule => new VatNumber(self::strings($p) === [] ? null : self::strings($p)), // Markup 'xml' => static fn (array $p): ValidationRule => new Xml(self::nullableStr($p, 0)), diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 42c2dab..e6fd122 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('79 rules') + ->expectsOutputToContain('80 rules') ->assertSuccessful(); }); diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index e7b33b5..42fad38 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(79); + ->and(count($classes))->toBe(80); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Fiscal/VatNumberTest.php b/tests/Rules/Fiscal/VatNumberTest.php new file mode 100644 index 0000000..d4772d5 --- /dev/null +++ b/tests/Rules/Fiscal/VatNumberTest.php @@ -0,0 +1,90 @@ +toBeTrue(); +})->with([ + 'NL123456782B01', + 'BE0423456765', + 'DE123456788', + 'IT12345678903', + 'SE123456789701', + 'EL123456783', + 'LU12345613', + 'FR32123456789', +]); + +it('rejects the same numbers with one digit off', function (string $value): void { + // A pattern-only port would pass every one of these. + expect(ruleAccepts(new VatNumber(), $value))->toBeFalse(); +})->with([ + 'NL123456783B01', + 'BE0423456766', + 'DE123456789', + 'IT12345678904', + 'SE123456789801', + 'EL123456784', + 'LU12345614', + 'FR33123456789', +]); + +it('accepts pattern-only countries and punctuation people actually type', function (string $value): void { + expect(ruleAccepts(new VatNumber(), $value))->toBeTrue(); +})->with([ + 'ATU12345678', + 'PL1234567890', + 'ESA1234567B', + 'BE 0423.456.765', // spaces and dots stripped + 'nl123456782b01', // case folded +]); + +it('rejects unknown prefixes and malformed tails', function (mixed $value): void { + expect(ruleAccepts(new VatNumber(), $value))->toBeFalse(); +})->with([ + 'XX123456789', + 'NL123456782', // missing the B-suffix + 'DE12345678', // eight digits + 'ATU1234567Z9', + '123456789', // no country at all + 12345, + null, +]); + +it('can be restricted to named countries', function (): void { + $benelux = new VatNumber(['NL', 'BE', 'LU']); + + expect(ruleAccepts($benelux, 'NL123456782B01'))->toBeTrue() + ->and(ruleAccepts($benelux, 'DE123456788'))->toBeFalse(); +}); + +// ========================================================================= +// NationalIdentifier — the VN scheme +// ========================================================================= + +it('accepts well-formed Vietnamese CCCD numbers', function (string $value): void { + expect(ruleAccepts(new NationalIdentifier(NationalIdentifier::VN), $value))->toBeTrue(); +})->with([ + '001204012345', // Hanoi, male born 20xx + '096199123456', // the highest assigned province code +]); + +it('rejects CCCD numbers outside the scheme', function (mixed $value): void { + expect(ruleAccepts(new NationalIdentifier(NationalIdentifier::VN), $value))->toBeFalse(); +})->with([ + '000204012345', // province 000 is not assigned + '097204012345', // beyond the assigned range + '00120401234', // eleven digits + '0012040123456', // thirteen + '001204O12345', // letter smuggled in + 12, + null, +]); From c40b563c7df3371b0b17d56a2030fbf17e37f270 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 06:16:47 -0400 Subject: [PATCH 10/11] Add CompareToColumn, FileExistsOnDisk and the two redesigned probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompareToColumn replaces the legacy five-class cross-row family with one Database-tier rule parameterised by a Comparison enum; the key is a literal or an @sibling field, comparison is numeric when both sides are ('9' < '10', not the lexicographic lie), and a missing row FAILS — the moment the reference is wrong is the moment enforcement matters. FileExistsOnDisk treats its directory scope as a security boundary: '..' segments, absolute paths, backslashes and null bytes are refused rather than normalised, because '../top-level.txt' existing is exactly the traversal the legacy concatenation allowed. It opens the Storage tier, with its own arch guard. ImageUrl and HasGravatar implement the owner's decision to revive the two probes the plan recommended dropping. ImageUrl HEAD-probes for an image content type with the guards the legacy never had — scheme and loopback/reserved-IP screening before any request, no redirects, bounded timeout — and fails when unreachable, because the probe IS the rule. HasGravatar moves to https and the sha256 endpoint and passes on outage (the DeliverableEmail posture), with the privacy cost stated. Also the two-hour lesson: both probes initially implemented PrecognitionSkippable without the SkipsPrecognition trait, and the resulting autoload fatal was invisible under display_errors=Off — every arch-test run died silently at exit 1. The tier arch tests are now split per facade so the next such failure names its tier. --- CHANGELOG.md | 2 +- docs/tools/rule-library.md | 45 +++++++- resources/lang/en/validation.php | 8 ++ src/Commands/RulesCommand.php | 2 + src/Rules/Database/CompareToColumn.php | 87 +++++++++++++++ src/Rules/Database/Comparison.php | 40 +++++++ src/Rules/Network/HasGravatar.php | 69 ++++++++++++ src/Rules/Network/ImageUrl.php | 100 +++++++++++++++++ src/Rules/Storage/FileExistsOnDisk.php | 52 +++++++++ src/Support/RuleAliases.php | 19 ++++ tests/ConsoleCommandsTest.php | 2 +- tests/RuleAliasesTest.php | 2 + tests/RuleRegistrarTest.php | 2 +- tests/Rules/Database/CompareToColumnTest.php | 79 +++++++++++++ tests/Rules/Network/ProbeRulesTest.php | 111 +++++++++++++++++++ tests/Rules/RuleTierTest.php | 15 ++- tests/Rules/Storage/FileExistsOnDiskTest.php | 33 ++++++ 17 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 src/Rules/Database/CompareToColumn.php create mode 100644 src/Rules/Database/Comparison.php create mode 100644 src/Rules/Network/HasGravatar.php create mode 100644 src/Rules/Network/ImageUrl.php create mode 100644 src/Rules/Storage/FileExistsOnDisk.php create mode 100644 tests/Rules/Database/CompareToColumnTest.php create mode 100644 tests/Rules/Network/ProbeRulesTest.php create mode 100644 tests/Rules/Storage/FileExistsOnDiskTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee873b..36e91f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se Pest testing helpers. - Four Laravel Boost skills shipped under `resources/boost/skills/`, namespaced `laranail-validation*`. -- An extended rule library under `Rules\`: 80 rules across AntiSpam, Banking, Codes, Colour, +- An extended rule library under `Rules\`: 84 rules across AntiSpam, Banking, Codes, Colour, Crypto, Database, Email, Fiscal, Geo, I18n, Identifiers, Markup, Net, Network, Numbers, Postal, Profanity, Structure, Telecom, Text and Vendor. Each is a plain `ValidationRule` usable on its own, in a rule array, or through the builder's `rule()` escape hatch. See diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index bb7d2a3..0f2dd55 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -29,9 +29,9 @@ The email rules also have named methods on the email node — see [Fluent rule]( | **Alias** | The [opt-in string form](../configuration.md#string-rule-aliases-are-opt-in), behind the `laranail_` prefix. Off by default | | **Message key** | Under `laranail-validation::validation.`, overridable per application | -Every rule here is **Pure tier** except the two in `Database`, which perform one indexed read -each, and the one in `Network`, which performs a cached DNS lookup. No rule in this library -writes. See [Architecture](../architecture.md) for what the tiers guarantee and the arch tests +Every rule here is **Pure tier** except the `Database` family (one indexed read each), the +`Storage` rule (one disk-metadata read), and the `Network` family (DNS or HTTP probes, each +skipped during a precognitive request). No rule in this library writes. See [Architecture](../architecture.md) for what the tiers guarantee and the arch tests that enforce them. ## Anti-spam @@ -210,12 +210,13 @@ Hex alone overlaps Laravel's native `hex_color` — use the native rule if that ## Database -The only two rules that touch the database. One indexed read each, no writes. +The only rules that touch the database. One indexed read each, no writes. | Rule | Parameters | Alias | Message key | |---|---|---|---| | `Authorized` | `string $ability, string $model, ?string $guard = null, array $arguments = []` | `authorized` | `authorized` | | `ModelsExist` | `string $model, ?string $column = null` | `models_exist` | `models_exist.array`, `models_exist.missing` | +| `CompareToColumn` | `string $table, string $column, Comparison $operator, string $keyColumn, int\|float\|string $key` | `compare_to_column` | `compare_to_column` | - **`Authorized`** — resolves the submitted identifier to a model, then asks the Gate. This is the difference from the native `Rule::can()`, which hands the policy the raw submitted value: @@ -228,6 +229,15 @@ The only two rules that touch the database. One indexed read each, no writes. input are counted once: a repeated selection is a UI artefact, not a missing record. `$column` defaults to the model's route key. +**`CompareToColumn`** — the value compared against a column looked up in another row: +`new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', +'@product_id')` — "quantity may not exceed THIS product's max". The key is a literal or +`@field` for a sibling; the operator is the `Comparison` enum +(`laranail_compare_to_column:products,max_quantity,lte,id,@product_id`), replacing the legacy +five-class family. Comparison is numeric when both sides are numeric — `'9' < '10'`, not the +lexicographic lie. A missing row **fails** the value: the bound could not be checked, and that +is the moment enforcement matters. + ## Email Domain and mailbox rules. Deliverability needs DNS, so it lives in the Network tier below. @@ -478,6 +488,8 @@ The only tier that performs IO, and the only rules skipped during a precognitive | Rule | Parameters | Alias | Message key | |---|---|---|---| | `DeliverableEmail` | `?DnsResolver $resolver = null` | `deliverable_email` | `email.undeliverable`, `email.malformed` | +| `ImageUrl` | `array $mimes = [], int $timeoutSeconds = 3` | `image_url` | `image_url` | +| `HasGravatar` | `int $timeoutSeconds = 3` | `has_gravatar` | `has_gravatar` | **`DeliverableEmail`** — the address's domain can actually receive mail. One DNS lookup, behind the `DnsResolver` contract so it is injectable, cached and fakeable; Laravel's own `email:dns` @@ -507,6 +519,31 @@ The bundled `Actions\CachedDnsResolver` is bound only if nothing else has bound so `laranail/email` can replace it without any call site changing. Cache lifetime is `laranail.validation.dns.ttl`, default one hour. +**`ImageUrl`** — the URL currently serves an image: a HEAD probe expecting 200 with an +`image/*` content type (optionally restricted, `mimes: ['png']`). The probe IS the rule, so +unreachable **fails** — expect that trade before using it. Guards: http/https only, loopback +names and non-routable IP literals refused before any request, redirects never followed, +bounded timeout. Hygiene, not an SSRF boundary — a public name resolving to a private address +needs an egress-layer defence; do not point this at hostile input without one. + +**`HasGravatar`** — the email address has a Gravatar, probed over https with the sha256 hash +against the `d=404` endpoint. Unreachable **passes** (the `DeliverableEmail` posture: a third +party's outage must not turn away users). Privacy is the cost and the function: a hash of the +email goes to gravatar.com at validation time — name it in your privacy policy, and prefer +probing after signup when the avatar is a nicety rather than a requirement. + +## Storage + +| Rule | Parameters | Alias | Message key | +|---|---|---|---| +| `FileExistsOnDisk` | `string $disk, string $directory = ''` | `file_exists_on_disk` | `file_exists_on_disk` | + +**`FileExistsOnDisk`** — the value names a file on a Laravel disk, optionally scoped to a +directory (`laranail_file_exists_on_disk:uploads,avatars`). The scope is a security boundary: +`..` segments, absolute paths, backslashes and null bytes are refused outright rather than +normalised — `../top-level.txt` may well exist, which is exactly the point. One metadata read; +on an S3-style disk that is a network round trip per validated value. + ## Vendor identifiers | Rule | Parameters | Alias | Message key | diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 0ab9a8e..d13a05f 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -110,6 +110,13 @@ 'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.', + // Storage + 'file_exists_on_disk' => 'The :attribute must name an existing file.', + + // Networking probes + 'has_gravatar' => 'The :attribute must have a Gravatar.', + 'image_url' => 'The :attribute must be a URL serving an image.', + // Payment 'card_cvc' => 'The :attribute must be a valid security code.', 'card_expiry' => 'The :attribute must be a valid expiry date that has not passed.', @@ -162,6 +169,7 @@ // Database 'authorized' => 'The selected :attribute is invalid.', + 'compare_to_column' => 'The :attribute is outside the allowed bound.', 'models_exist' => [ 'array' => 'The :attribute must be a list of identifiers.', 'missing' => 'The :attribute contains values that do not exist: :values.', diff --git a/src/Commands/RulesCommand.php b/src/Commands/RulesCommand.php index 1034db5..9502a23 100644 --- a/src/Commands/RulesCommand.php +++ b/src/Commands/RulesCommand.php @@ -70,6 +70,8 @@ private function aliasIndex(): array 'minute_in' => ['0', '15', '30', '45'], 'max_date_difference' => ['48', '2026-08-24 00:00:00'], 'minimum_age' => ['18'], + 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], + 'file_exists_on_disk' => ['local'], 'hash_digest' => ['sha256'], 'max_words' => ['200'], 'min_words' => ['2'], diff --git a/src/Rules/Database/CompareToColumn.php b/src/Rules/Database/CompareToColumn.php new file mode 100644 index 0000000..15cbedb --- /dev/null +++ b/src/Rules/Database/CompareToColumn.php @@ -0,0 +1,87 @@ + */ + private array $data = []; + + public function __construct( + private readonly string $table, + private readonly string $column, + private readonly Comparison $operator, + private readonly string $keyColumn, + private readonly int|float|string $key, + ) {} + + /** @param array $data */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_scalar($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.compare_to_column')->translate(); + } + } + + private function passes(int|float|string|bool $value): bool + { + $key = $this->resolveKey(); + + if ($key === null) { + return false; + } + + $found = DB::table($this->table)->where($this->keyColumn, $key)->value($this->column); + + if (! is_scalar($found)) { + return false; + } + + return $this->operator->compare((string) $value, (string) $found); + } + + private function resolveKey(): int|float|string|null + { + if (! is_string($this->key) || ! str_starts_with($this->key, '@')) { + return $this->key; + } + + $sibling = Arr::get($this->data, substr($this->key, 1)); + + return is_int($sibling) || is_float($sibling) || is_string($sibling) ? $sibling : null; + } +} diff --git a/src/Rules/Database/Comparison.php b/src/Rules/Database/Comparison.php new file mode 100644 index 0000000..f2675f4 --- /dev/null +++ b/src/Rules/Database/Comparison.php @@ -0,0 +1,40 @@ + $left < $right, + self::LessThanOrEqual => $left <= $right, + self::GreaterThan => $left > $right, + self::GreaterThanOrEqual => $left >= $right, + self::Equal => ($left <=> $right) === 0, + self::NotEqual => ($left <=> $right) !== 0, + }; + } +} diff --git a/src/Rules/Network/HasGravatar.php b/src/Rules/Network/HasGravatar.php new file mode 100644 index 0000000..0d89c14 --- /dev/null +++ b/src/Rules/Network/HasGravatar.php @@ -0,0 +1,69 @@ +shouldSkipPrecognition()) { + return; + } + + if (! is_string($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.has_gravatar')->translate(); + } + } + + private function passes(string $value): bool + { + if (Address::split($value) === null) { + return false; + } + + $hash = hash('sha256', strtolower(trim($value))); + + try { + $response = Http::timeout($this->timeoutSeconds) + ->connectTimeout($this->timeoutSeconds) + ->head('https://gravatar.com/avatar/' . $hash . '?d=404'); + } catch (ConnectionException) { + return true; + } + + return $response->status() === 200; + } +} diff --git a/src/Rules/Network/ImageUrl.php b/src/Rules/Network/ImageUrl.php new file mode 100644 index 0000000..0e016b3 --- /dev/null +++ b/src/Rules/Network/ImageUrl.php @@ -0,0 +1,100 @@ + $mimes Accepted `image/*` subtypes; empty accepts any image. + */ + public function __construct( + private readonly array $mimes = [], + private readonly int $timeoutSeconds = 3, + ) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + // Before the IO: a precognitive preview must cost nothing. + if ($this->shouldSkipPrecognition()) { + return; + } + + if (! is_string($value) || ! $this->passes($value)) { + $fail('laranail-validation::validation.image_url')->translate(); + } + } + + private function passes(string $url): bool + { + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + return false; + } + + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + $host = strtolower(trim((string) parse_url($url, PHP_URL_HOST), '[]')); + + if (! in_array($scheme, ['http', 'https'], true) || $host === '' + || in_array($host, self::LOOPBACK_NAMES, true)) { + return false; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false && ! IpClassifier::isPubliclyRoutable($host)) { + return false; + } + + try { + $response = Http::timeout($this->timeoutSeconds) + ->connectTimeout($this->timeoutSeconds) + ->withOptions(['allow_redirects' => false]) + ->head($url); + } catch (ConnectionException) { + return false; + } + + if ($response->status() !== 200) { + return false; + } + + $type = strtolower(explode(';', $response->header('Content-Type'))[0]); + + if (! str_starts_with($type, 'image/')) { + return false; + } + + return $this->mimes === [] + || in_array(substr($type, strlen('image/')), $this->mimes, true); + } +} diff --git a/src/Rules/Storage/FileExistsOnDisk.php b/src/Rules/Storage/FileExistsOnDisk.php new file mode 100644 index 0000000..f616d27 --- /dev/null +++ b/src/Rules/Storage/FileExistsOnDisk.php @@ -0,0 +1,52 @@ +passes($value)) { + $fail('laranail-validation::validation.file_exists_on_disk')->translate(); + } + } + + private function passes(string $value): bool + { + if ($value === '' || str_contains($value, "\0") || str_contains($value, '\\') + || str_starts_with($value, '/') + || preg_match('#(?:^|/)\.\.(?:/|$)#', $value) === 1) { + return false; + } + + $path = $this->directory === '' ? $value : rtrim($this->directory, '/') . '/' . $value; + + return Storage::disk($this->disk)->exists($path); + } +} diff --git a/src/Support/RuleAliases.php b/src/Support/RuleAliases.php index 39ddfe5..36c1100 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -33,6 +33,8 @@ use Simtabi\Laranail\Validation\Rules\Crypto\BitcoinAddress; use Simtabi\Laranail\Validation\Rules\Crypto\EthereumAddress; use Simtabi\Laranail\Validation\Rules\Database\Authorized; +use Simtabi\Laranail\Validation\Rules\Database\CompareToColumn; +use Simtabi\Laranail\Validation\Rules\Database\Comparison; use Simtabi\Laranail\Validation\Rules\Database\ModelsExist; use Simtabi\Laranail\Validation\Rules\Email\EmailDomainIs; use Simtabi\Laranail\Validation\Rules\Email\EmailDomainIsNot; @@ -67,6 +69,8 @@ use Simtabi\Laranail\Validation\Rules\Net\Subdomain; use Simtabi\Laranail\Validation\Rules\Net\Url; use Simtabi\Laranail\Validation\Rules\Network\DeliverableEmail; +use Simtabi\Laranail\Validation\Rules\Network\HasGravatar; +use Simtabi\Laranail\Validation\Rules\Network\ImageUrl; use Simtabi\Laranail\Validation\Rules\Numbers\MonetaryAmount; use Simtabi\Laranail\Validation\Rules\Numbers\Parity; use Simtabi\Laranail\Validation\Rules\Payment\CardCvc; @@ -74,6 +78,7 @@ use Simtabi\Laranail\Validation\Rules\Payment\CardNumber; use Simtabi\Laranail\Validation\Rules\Postal\PostalCode; use Simtabi\Laranail\Validation\Rules\Profanity\NoProfanity; +use Simtabi\Laranail\Validation\Rules\Storage\FileExistsOnDisk; use Simtabi\Laranail\Validation\Rules\Structure\Delimited; use Simtabi\Laranail\Validation\Rules\Text\CaseStyle; use Simtabi\Laranail\Validation\Rules\Text\HtmlClean; @@ -191,6 +196,20 @@ public static function map(): array // to read the country from a sibling field. 'postal_code' => self::postalCode(...), + // Database — `laranail_compare_to_column:products,max_quantity,lte,id,@product_id` + 'compare_to_column' => static fn (array $p): ValidationRule => new CompareToColumn( + self::str($p, 0), self::str($p, 1), + Comparison::from(self::str($p, 2, 'eq')), + self::str($p, 3), self::str($p, 4), + ), + + // Storage — `laranail_file_exists_on_disk:uploads,avatars` + 'file_exists_on_disk' => static fn (array $p): ValidationRule => new FileExistsOnDisk(self::str($p, 0), self::str($p, 1)), + + // Networking probes + 'image_url' => static fn (array $p): ValidationRule => new ImageUrl(self::strings($p)), + 'has_gravatar' => static fn (): ValidationRule => new HasGravatar(), + // Payment — `laranail_card_number:visa,mastercard` restricts // brands; `laranail_card_cvc:card_number` names the sibling // number field; `laranail_card_expiry:Europe/Berlin` the zone. diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index e6fd122..1ef43f7 100644 --- a/tests/ConsoleCommandsTest.php +++ b/tests/ConsoleCommandsTest.php @@ -11,7 +11,7 @@ */ it('lists the live registry through laranail::validation.rules', function (): void { $this->artisan('laranail::validation.rules') - ->expectsOutputToContain('80 rules') + ->expectsOutputToContain('84 rules') ->assertSuccessful(); }); diff --git a/tests/RuleAliasesTest.php b/tests/RuleAliasesTest.php index 91c8e97..5111330 100644 --- a/tests/RuleAliasesTest.php +++ b/tests/RuleAliasesTest.php @@ -37,6 +37,8 @@ function sampleParameters(): array 'minute_in' => ['0', '15', '30', '45'], 'max_date_difference' => ['48', '2026-08-24 00:00:00'], 'minimum_age' => ['18'], + 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], + 'file_exists_on_disk' => ['local'], 'hash_digest' => ['sha256'], 'max_words' => ['200'], 'min_words' => ['2'], diff --git a/tests/RuleRegistrarTest.php b/tests/RuleRegistrarTest.php index 42fad38..9b80322 100644 --- a/tests/RuleRegistrarTest.php +++ b/tests/RuleRegistrarTest.php @@ -22,7 +22,7 @@ expect($classes)->toContain(Iban::class) ->toContain(Phone::class) - ->and(count($classes))->toBe(80); + ->and(count($classes))->toBe(84); }); it('accepts a consumer registration with an alias factory', function (): void { diff --git a/tests/Rules/Database/CompareToColumnTest.php b/tests/Rules/Database/CompareToColumnTest.php new file mode 100644 index 0000000..ad21988 --- /dev/null +++ b/tests/Rules/Database/CompareToColumnTest.php @@ -0,0 +1,79 @@ + 'testing']); + config(['database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:']]); + + Schema::connection('testing')->create('products', function (Blueprint $table): void { + $table->id(); + $table->integer('max_quantity'); + $table->string('tier'); + }); + + DB::connection('testing')->table('products')->insert([ + ['id' => 1, 'max_quantity' => 10, 'tier' => 'basic'], + ['id' => 2, 'max_quantity' => 100, 'tier' => 'pro'], + ]); +}); + +it('compares the value against the looked-up column across the operators', function (): void { + $rule = static fn (Comparison $op): CompareToColumn => new CompareToColumn( + 'products', 'max_quantity', $op, 'id', 1, + ); + + expect(ruleAccepts($rule(Comparison::LessThanOrEqual), '10'))->toBeTrue() + ->and(ruleAccepts($rule(Comparison::LessThanOrEqual), '11'))->toBeFalse() + ->and(ruleAccepts($rule(Comparison::LessThan), '9'))->toBeTrue() + ->and(ruleAccepts($rule(Comparison::LessThan), '10'))->toBeFalse() + ->and(ruleAccepts($rule(Comparison::GreaterThanOrEqual), '10'))->toBeTrue() + ->and(ruleAccepts($rule(Comparison::GreaterThan), '10'))->toBeFalse() + ->and(ruleAccepts($rule(Comparison::Equal), '10'))->toBeTrue() + ->and(ruleAccepts($rule(Comparison::NotEqual), '10'))->toBeFalse() + ->and(ruleAccepts($rule(Comparison::NotEqual), '11'))->toBeTrue(); +}); + +it('compares numerically, not lexicographically', function (): void { + // '9' > '10' as strings — the failure a naive string comparison bakes in. + $rule = new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', 2); + + expect(ruleAccepts($rule, '99'))->toBeTrue() + ->and(ruleAccepts($rule, '101'))->toBeFalse(); +}); + +it('reads an @-prefixed key from a sibling field', function (): void { + $rules = [ + 'product_id' => 'required', + 'quantity' => [new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', '@product_id')], + ]; + + expect(Validator::make(['product_id' => 1, 'quantity' => '10'], $rules)->passes())->toBeTrue() + ->and(Validator::make(['product_id' => 1, 'quantity' => '11'], $rules)->passes())->toBeFalse() + ->and(Validator::make(['product_id' => 2, 'quantity' => '11'], $rules)->passes())->toBeTrue(); +}); + +it('fails when the referenced row does not exist', function (): void { + // A missing row means the bound could not be checked; passing would + // enforce nothing exactly when the data is most suspect. + $rule = new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', 999); + + expect(ruleAccepts($rule, '1'))->toBeFalse(); +}); + +it('fails non-scalar values', function (): void { + $rule = new CompareToColumn('products', 'max_quantity', Comparison::LessThanOrEqual, 'id', 1); + + expect(ruleAccepts($rule, ['10']))->toBeFalse() + ->and(ruleAccepts($rule, null))->toBeFalse(); +}); + +it('resolves operators from their string tokens for the alias form', function (): void { + expect(Comparison::from('lte'))->toBe(Comparison::LessThanOrEqual) + ->and(Comparison::from('neq'))->toBe(Comparison::NotEqual); +}); diff --git a/tests/Rules/Network/ProbeRulesTest.php b/tests/Rules/Network/ProbeRulesTest.php new file mode 100644 index 0000000..393a797 --- /dev/null +++ b/tests/Rules/Network/ProbeRulesTest.php @@ -0,0 +1,111 @@ + Http::response('', 200, ['Content-Type' => 'image/png'])]); + + expect(ruleAccepts(new ImageUrl(), 'https://example.com/logo.png'))->toBeTrue(); +}); + +it('rejects non-image content, error statuses and unreachable hosts', function (): void { + Http::fake([ + 'text.example.com/*' => Http::response('', 200, ['Content-Type' => 'text/html']), + 'gone.example.com/*' => Http::response('', 404), + 'down.example.com/*' => static fn () => throw new ConnectionException('refused'), + ]); + + expect(ruleAccepts(new ImageUrl(), 'https://text.example.com/page'))->toBeFalse() + ->and(ruleAccepts(new ImageUrl(), 'https://gone.example.com/x.png'))->toBeFalse() + // The probe IS the rule: unreachable fails, unlike DeliverableEmail's + // fail-open DNS posture, because "serves an image right now" is + // exactly what was asked. + ->and(ruleAccepts(new ImageUrl(), 'https://down.example.com/x.png'))->toBeFalse(); +}); + +it('can restrict the image subtypes', function (): void { + Http::fake(['example.com/*' => Http::response('', 200, ['Content-Type' => 'image/gif'])]); + + expect(ruleAccepts(new ImageUrl(mimes: ['png', 'jpeg']), 'https://example.com/a.gif'))->toBeFalse() + ->and(ruleAccepts(new ImageUrl(mimes: ['gif']), 'https://example.com/a.gif'))->toBeTrue(); +}); + +it('refuses non-http schemes, loopback names and reserved-range IP literals without any request', function (mixed $value): void { + Http::fake(); + + expect(ruleAccepts(new ImageUrl(), $value))->toBeFalse(); + + Http::assertNothingSent(); +})->with([ + 'ftp://example.com/logo.png', + 'http://localhost/x.png', + 'http://127.0.0.1/x.png', + 'http://169.254.169.254/latest/meta-data', + 'http://[::1]/x.png', + 'not a url', + 12, + null, +]); + +it('probes with HEAD and follows no redirects', function (): void { + Http::fake(['example.com/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/'])]); + + expect(ruleAccepts(new ImageUrl(), 'https://example.com/logo.png'))->toBeFalse(); + + Http::assertSent(static fn (Request $request): bool => $request->method() === 'HEAD'); +}); + +it('is skipped during precognition', function (): void { + expect(new ImageUrl())->toBeInstanceOf(PrecognitionSkippable::class) + ->and(new HasGravatar())->toBeInstanceOf(PrecognitionSkippable::class); +}); + +// ========================================================================= +// HasGravatar — redesigned: https, sha256, fail-open on outage +// ========================================================================= + +it('reports whether the address has a gravatar via the sha256 endpoint', function (): void { + Http::fake([ + 'gravatar.com/avatar/' . hash('sha256', 'alice@example.com') . '*' => Http::response('', 200), + 'gravatar.com/*' => Http::response('', 404), + ]); + + expect(ruleAccepts(new HasGravatar(), 'alice@example.com'))->toBeTrue() + ->and(ruleAccepts(new HasGravatar(), 'nobody@example.com'))->toBeFalse(); +}); + +it('normalises the address before hashing, as gravatar does', function (): void { + Http::fake([ + 'gravatar.com/avatar/' . hash('sha256', 'alice@example.com') . '*' => Http::response('', 200), + 'gravatar.com/*' => Http::response('', 404), + ]); + + expect(ruleAccepts(new HasGravatar(), ' Alice@Example.COM '))->toBeTrue(); +}); + +it('passes rather than blocks when gravatar is unreachable', function (): void { + // The DeliverableEmail posture: a third party's outage must not turn + // away real users. Treat a pass as "not shown to be missing". + Http::fake(['gravatar.com/*' => static fn () => throw new ConnectionException('down')]); + + expect(ruleAccepts(new HasGravatar(), 'alice@example.com'))->toBeTrue(); +}); + +it('rejects a malformed address without asking gravatar', function (): void { + Http::fake(); + + expect(ruleAccepts(new HasGravatar(), 'not-an-email'))->toBeFalse() + ->and(ruleAccepts(new HasGravatar(), 42))->toBeFalse(); + + Http::assertNothingSent(); +}); diff --git a/tests/Rules/RuleTierTest.php b/tests/Rules/RuleTierTest.php index b7b307a..a1dbb8d 100644 --- a/tests/Rules/RuleTierTest.php +++ b/tests/Rules/RuleTierTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Simtabi\Laranail\Validation\Contracts\PrecognitionSkippable; // ========================================================================= @@ -56,11 +57,21 @@ function tierOf(string $path): string return explode('/', substr($path, $position + strlen($marker)))[0]; } -arch('pure-tier rules never reach the database, the gate or the network') +arch('pure-tier rules never reach the database or the gate') ->expect('Simtabi\Laranail\Validation\Rules') - ->not->toUse([DB::class, Gate::class, Http::class, HttpFactory::class]) + ->not->toUse([DB::class, Gate::class]) ->ignoring('Simtabi\Laranail\Validation\Rules\Database'); +arch('HTTP stays in the Network tier') + ->expect('Simtabi\Laranail\Validation\Rules') + ->not->toUse([Http::class, HttpFactory::class]) + ->ignoring('Simtabi\Laranail\Validation\Rules\Network'); + +arch('disk access stays in the Storage tier') + ->expect('Simtabi\Laranail\Validation\Rules') + ->not->toUse([Storage::class]) + ->ignoring('Simtabi\Laranail\Validation\Rules\Storage'); + it('has no rule that writes', function (): void { // Read-only is the strongest claim the library makes about itself: a rule // runs on unvalidated input, often several times per request, and may run diff --git a/tests/Rules/Storage/FileExistsOnDiskTest.php b/tests/Rules/Storage/FileExistsOnDiskTest.php new file mode 100644 index 0000000..dcca545 --- /dev/null +++ b/tests/Rules/Storage/FileExistsOnDiskTest.php @@ -0,0 +1,33 @@ +put('avatars/alice.png', 'png-bytes'); + Storage::disk('uploads')->put('top-level.txt', 'text'); +}); + +it('finds files on the named disk, with and without a directory scope', function (): void { + expect(ruleAccepts(new FileExistsOnDisk('uploads', 'avatars'), 'alice.png'))->toBeTrue() + ->and(ruleAccepts(new FileExistsOnDisk('uploads'), 'top-level.txt'))->toBeTrue() + ->and(ruleAccepts(new FileExistsOnDisk('uploads'), 'avatars/alice.png'))->toBeTrue() + ->and(ruleAccepts(new FileExistsOnDisk('uploads', 'avatars'), 'bob.png'))->toBeFalse() + ->and(ruleAccepts(new FileExistsOnDisk('uploads'), 'missing.txt'))->toBeFalse(); +}); + +it('refuses traversal out of the scoped directory', function (mixed $value): void { + // The scope is a security boundary: '../top-level.txt' EXISTS on the + // disk, and the legacy rule would have found it. + expect(ruleAccepts(new FileExistsOnDisk('uploads', 'avatars'), $value))->toBeFalse(); +})->with([ + '../top-level.txt', + 'x/../../top-level.txt', + '..', + '/etc/passwd', + '\\windows\\path', + "alice.png\0.txt", + 12, + null, +]); From 99d839e300eb2140d54bc518d50090c59b7c3108 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 24 Aug 2026 06:22:05 -0400 Subject: [PATCH 11/11] Close Phase 2: reserved-name contract, decision record, rector pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReservedUsernameList completes the §5.2.1 contract set. Its default binding is Username's own calibrated 35-name floor — introducing the contract changed no verdict — while ExtendedReservedUsernameList ships the full ~350-entry curated set (the archive's list with its OCR artifacts repaired and its profanity removed: what counts as offensive is TermList policy, per this package's own documented stance). An application binds the extended list, or its own, to change policy everywhere without touching a rule; reserved: on one instance still wins. docs/architecture.md gains 'Not in this library — and why', the durable record of every remaining deliberate drop and relocation from the parity sweep, so none gets re-litigated one rule at a time. The rule-library routing table now points country/currency/language CODES and the everyday time rules at their new in-core homes. Rector ran locally so the branch bot has nothing to add; its one overreach (a CardBrand[] docblock weaker than the contract's list<>) is corrected here. --- docs/architecture.md | 41 ++ docs/tools/rule-library.md | 4 +- resources/data/reserved-usernames.txt | 360 ++++++++++++++++++ src/Contracts/ReservedUsernameList.php | 31 ++ src/Rules/Chrono/DateInterval.php | 4 +- src/Rules/Chrono/MaxDateDifference.php | 10 +- src/Rules/Chrono/MinimumAge.php | 6 +- src/Rules/Chrono/MinuteIn.php | 4 +- src/Rules/Chrono/TimeOfDay.php | 6 +- src/Rules/Chrono/UnixTimestamp.php | 4 +- src/Rules/Codes/UpcE.php | 2 +- src/Rules/Encoding/Base64Image.php | 6 +- src/Rules/Encoding/DataUri.php | 4 +- src/Rules/Fiscal/VatNumber.php | 28 +- src/Rules/Identifiers/HashDigest.php | 4 +- src/Rules/Network/HasGravatar.php | 4 +- src/Rules/Network/ImageUrl.php | 6 +- src/Rules/Payment/CardCvc.php | 3 +- src/Rules/Payment/CardExpiry.php | 6 +- src/Rules/Payment/CardNumber.php | 3 +- src/Rules/Storage/FileExistsOnDisk.php | 6 +- src/Rules/Text/HtmlClean.php | 4 +- src/Rules/Text/MaxWords.php | 4 +- src/Rules/Text/MinWords.php | 4 +- src/Rules/Text/Salutation.php | 2 +- src/Rules/Text/Username.php | 23 +- .../Text/DefaultReservedUsernameList.php | 19 + .../Text/ExtendedReservedUsernameList.php | 29 ++ src/ValidationServiceProvider.php | 3 + tests/Rules/Chrono/ChronoRulesTest.php | 8 +- tests/Rules/Encoding/EncodingRulesTest.php | 8 +- tests/Rules/I18n/I18nRulesTest.php | 4 +- tests/Rules/Payment/PaymentRulesTest.php | 11 +- tests/Rules/Text/ReservedUsernameListTest.php | 38 ++ 34 files changed, 622 insertions(+), 77 deletions(-) create mode 100644 resources/data/reserved-usernames.txt create mode 100644 src/Contracts/ReservedUsernameList.php create mode 100644 src/Support/Text/DefaultReservedUsernameList.php create mode 100644 src/Support/Text/ExtendedReservedUsernameList.php create mode 100644 tests/Rules/Text/ReservedUsernameListTest.php diff --git a/docs/architecture.md b/docs/architecture.md index fe5556b..e6a4b20 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -241,6 +241,47 @@ So the four skills carry the vendor and slug themselves: | `laranail-validation-optimize` | Finding conversion opportunities in existing validation | | `laranail-validation-migrate-messages` | Moving `messages(): array` to inline `message:` | +## Not in this library — and why + +The migration audit swept every feature of the legacy packages (`enekia` v1/v2). Most were +ported, usually improved; the rest were dropped or relocated **on purpose**, and this section +is the durable record of each decision so nobody re-litigates them one at a time. + +- **`RemoteRule`** (a rule POSTing the value to a DB-configured URL) — dropped as designed. + A validation rule that ships user input to a runtime-configurable endpoint is an SSRF and + exfiltration hazard wearing a helpful name. The sanctioned shapes are a container-resolved + invokable rule (your code, your endpoint) or the validation-js transport layer, which is + security-reviewed before any dynamic endpoint ships. +- **SMTP deep-check verification** — `laranail/email`'s scope. `Networking\DeliverableEmail` + answers the narrower MX question here; an SMTP conversation needs sending infrastructure a + validation library must not own. +- **zxcvbn strength scoring / password history** — their own packages + (`laranail/password-strength`, `laranail/password-history`), bridged into `password()` via + guarded macros and listed under `suggest` only. +- **Disposable-phone lists** — `laranail/phone`'s scope, beside the phone metadata they + describe. +- **The framework-free PHP tier** — enekia v1's vanilla validators are not restored. The + successor is Laravel-coupled by design; the portable role belongs to the JS engine, which is + framework-free from the ground up. +- **`is*()`/`assert*()` magic statics** — replaced by the typed fluent API and the explicit + `Check::` statics, which give the same one-liner ergonomics with real signatures. +- **`Transfigure`'s ~55 type predicates** — a utility library that lived inside a validation + package; neither ported nor replaced, because type predicates are not validation rules. +- **`Str` contains-all/contains-any rules** — deferred, not refused: core `contains` plus the + string builder cover the common cases, and the dedicated rules land if real demand appears. +- **`MissingFromDB`** — expressible as `Rule::unique` directly; a wrapper would rename a core + rule without changing it. +- **JAN and UPC-A** — documented onto `Ean` and `Gtin` rather than duplicated: each IS the + other rule with a prefix, and a prefix-only class would restate a checksum to say less. + +Two probes the plan recommended dropping were instead **redesigned and implemented** by owner +decision — `Networking\ImageUrl` (guarded, redirect-refusing, fail-closed) and +`Networking\HasGravatar` (https, sha256, fail-open, privacy cost documented). The enekia +v1-only rules all resolve to core or existing successors: `Equals` → `same`, `Coordinate` → +`Geo\LatLng`, `Timezone` → core `timezone` (identifiers) + `Chrono\TimezoneAbbreviation` +(abbreviations), `IsAStateInNorthAmerica` → `Geo\UsState` / `Geo\CaProvince`, +`IncludesHtml` → `Text\HtmlClean`'s `mustContainHtml:` flag. + --- [← Docs index](../README.md#documentation) diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 0f2dd55..2cd590e 100644 --- a/docs/tools/rule-library.md +++ b/docs/tools/rule-library.md @@ -818,9 +818,9 @@ Only English ships. The package deliberately does not carry translations it cann | You want | It lives in | |---|---| -| Country / currency / language codes | `laranail/atlas` | +| Country / currency / language **data** (names, regions, coordinates) | `laranail/atlas` — the code rules ship here ([I18n](#i18n)) and atlas can rebind their datasets | | Enum values, names, transitions | `laranail/enumerator` | -| Timezones, date existence and ambiguity | `laranail/chrono` | +| Timezone identifiers, date existence and ambiguity | `laranail/chrono` — the everyday time rules ship here ([Chrono](#chrono)) | | Password strength, common-password rejection | `laranail/toolkit` | | Captcha verification | `laranail/captcha` | | Licence keys | `laranail/license-kit` | diff --git a/resources/data/reserved-usernames.txt b/resources/data/reserved-usernames.txt new file mode 100644 index 0000000..ab6ef6f --- /dev/null +++ b/resources/data/reserved-usernames.txt @@ -0,0 +1,360 @@ +# Reserved usernames: names a public site's own routes, infrastructure and +# staff impersonation surface need to keep — one per line, lowercase. +# Derived 2026-08-24 from the enekia archive's reserved list with its OCR +# artifacts (rnail, cnarne, htrnl...) repaired and its profanity entries +# removed: what counts as offensive is TermList policy, deliberately not +# bundled data (see Contracts\TermList). Bind your own ReservedUsernameList +# to replace this set. +about +aboutus +abuse +acme +ad +admanager +admin +admindashboard +administrator +ads +adsense +adword +adwords +affiliate +affiliatepage +affiliates +afp +alpha +analytic +android +answer +api +api1 +api2 +api3 +app +appengine +application +appnew +asset +assets +assets1 +assets2 +assets3 +assets4 +assets5 +atf +backup +bank +base +beginner +beta +billing +binary +biz +blackberry +blog +blogsearch +book +buy +buzz +cache +calendar +cart +catalog +ceo +chart +chat +checkout +ci +cia +client +clients +cname +code +community +confirm +confirmation +contact +contactus +content +controlpanel +core +corp +country +cp +cpanel +cs +css +css1 +css2 +css3 +cv +dashboard +data +demo +deploy +deployment +desktop +dev +devel +developement +developer +developers +development +dir +directory +discussion +dl +doc +docs +document +donate +download +earth +email +enable +encrypted +engine +error +errorlog +fbi +feature +feed +feedburner +feedproxy +feeds +file +files +finance +folder +forgotpassword +forum +friend +ftp +fun +fusion +gadget +gear +geographic +gettingstarted +git +gitlab +gmail +go +goto +gov +graph +graphs +group +help +home +html +http +https +image +images +imap +img +img1 +img2 +img3 +investor +invoice +invoices +io +ios +ipad +iphone +item +jenkin +jira +job +join +js +js1 +js2 +kb +knowledgebase +lab +legal +list +lmao +lmfao +local +locale +location +log +login +logout +logs +mail +manage +manager +map +marketing +me +media +message +microsoft +misc +mms +mobile +model +money +movie +mx +my +mystore +net +network +networks +new +news +newsite +npm +ns +ns1 +ns2 +ns3 +ns4 +ns5 +omg +online +order +org +other +pack +packagist +page +pages +partner +partnerpage +partners +password +payment +payments +people +person +place +podcast +policy +pop +pop3 +popular +press +pricing +print +privacy +private +prod +product +production +profile +promo +promotion +proxies +proxy +public +purchase +queries +query +radio +random +reader +recover +redirect +register +registration +release +report +research +resolve +resolver +root +rss +sale +sandbox +scholar +search +secure +seminar +server +servers +service +sftp +shop +shopping +shortcut +signin +signup +site +sitemap +sitenew +sitenews +sites +sketchup +sky +slash +slashinvoice +sms +smtp +soap +software +sorry +spreadsheet +ssh +ssl +stage +staging +stat +static +statistic +statistics +stats +status +store +suggest +suggestquery +support +survey +surveys +surveytool +svn +sync +sysadmin +talk +talkgadget +test +tester +testing +text +tool +toolbar +trac +translate +translation +translator +trend +txt +upload +uploads +validation +validations +vid +video +video-stat +videos +voice +wave +webdisk +webmail +webmaster +whm +whois +wifi +wiki +wtf +www +www1 +www2 +xhtml +xml diff --git a/src/Contracts/ReservedUsernameList.php b/src/Contracts/ReservedUsernameList.php new file mode 100644 index 0000000..d345272 --- /dev/null +++ b/src/Contracts/ReservedUsernameList.php @@ -0,0 +1,31 @@ +app->singleton(ReservedUsernameList::class, ExtendedReservedUsernameList::class); + */ +interface ReservedUsernameList +{ + /** + * The reserved names, lowercase. Matching (case folding, separator + * stripping) is the rule's job, not the list's. + * + * @return list + */ + public function names(): array; +} diff --git a/src/Rules/Chrono/DateInterval.php b/src/Rules/Chrono/DateInterval.php index 8b98383..fab57ca 100644 --- a/src/Rules/Chrono/DateInterval.php +++ b/src/Rules/Chrono/DateInterval.php @@ -18,9 +18,9 @@ * * Pure tier — no IO. */ -final class DateInterval implements ValidationRule +final readonly class DateInterval implements ValidationRule { - public function __construct(private readonly bool $positive = false) {} + public function __construct(private bool $positive = false) {} public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/src/Rules/Chrono/MaxDateDifference.php b/src/Rules/Chrono/MaxDateDifference.php index b8aa13c..6993326 100644 --- a/src/Rules/Chrono/MaxDateDifference.php +++ b/src/Rules/Chrono/MaxDateDifference.php @@ -45,9 +45,9 @@ public function setData(array $data): static public function validate(string $attribute, mixed $value, Closure $fail): void { $reference = $this->reference(); - $subject = is_string($value) ? self::parse($value) : null; + $subject = is_string($value) ? $this->parse($value) : null; - if ($reference === null || $subject === null + if (! $reference instanceof DateTimeImmutable || ! $subject instanceof DateTimeImmutable || abs($subject->getTimestamp() - $reference->getTimestamp()) > $this->hours * 3600) { $fail('laranail-validation::validation.max_date_difference') ->translate(['hours' => $this->hours]); @@ -63,13 +63,13 @@ private function reference(): ?DateTimeImmutable if (str_starts_with($this->from, '@')) { $sibling = Arr::get($this->data, substr($this->from, 1)); - return is_string($sibling) ? self::parse($sibling) : null; + return is_string($sibling) ? $this->parse($sibling) : null; } - return self::parse($this->from); + return $this->parse($this->from); } - private static function parse(string $value): ?DateTimeImmutable + private function parse(string $value): ?DateTimeImmutable { try { return new DateTimeImmutable($value); diff --git a/src/Rules/Chrono/MinimumAge.php b/src/Rules/Chrono/MinimumAge.php index 5df320f..9074e8c 100644 --- a/src/Rules/Chrono/MinimumAge.php +++ b/src/Rules/Chrono/MinimumAge.php @@ -23,11 +23,11 @@ * * Pure tier — no IO. */ -final class MinimumAge implements ValidationRule +final readonly class MinimumAge implements ValidationRule { public function __construct( - private readonly int $years, - private readonly ?string $timezone = null, + private int $years, + private ?string $timezone = null, ) {} public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Rules/Chrono/MinuteIn.php b/src/Rules/Chrono/MinuteIn.php index 7bbaa60..c6b5e25 100644 --- a/src/Rules/Chrono/MinuteIn.php +++ b/src/Rules/Chrono/MinuteIn.php @@ -21,12 +21,12 @@ * * Pure tier — no IO. */ -final class MinuteIn implements ValidationRule +final readonly class MinuteIn implements ValidationRule { /** * @param list $minutes Allowed minute values, each 0–59. */ - public function __construct(private readonly array $minutes) + public function __construct(private array $minutes) { if ($minutes === []) { throw new LogicException('MinuteIn needs at least one allowed minute.'); diff --git a/src/Rules/Chrono/TimeOfDay.php b/src/Rules/Chrono/TimeOfDay.php index 44a42ae..2a7e508 100644 --- a/src/Rules/Chrono/TimeOfDay.php +++ b/src/Rules/Chrono/TimeOfDay.php @@ -20,11 +20,11 @@ * * Pure tier — no IO. */ -final class TimeOfDay implements ValidationRule +final readonly class TimeOfDay implements ValidationRule { public function __construct( - private readonly bool $twelveHour = false, - private readonly string $separator = ':', + private bool $twelveHour = false, + private string $separator = ':', ) {} public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Rules/Chrono/UnixTimestamp.php b/src/Rules/Chrono/UnixTimestamp.php index 52905e8..e0081e9 100644 --- a/src/Rules/Chrono/UnixTimestamp.php +++ b/src/Rules/Chrono/UnixTimestamp.php @@ -21,9 +21,9 @@ * * Pure tier — no IO. */ -final class UnixTimestamp implements ValidationRule +final readonly class UnixTimestamp implements ValidationRule { - public function __construct(private readonly bool $allowNegative = false) {} + public function __construct(private bool $allowNegative = false) {} public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/src/Rules/Codes/UpcE.php b/src/Rules/Codes/UpcE.php index 6243b8c..7b9c2bd 100644 --- a/src/Rules/Codes/UpcE.php +++ b/src/Rules/Codes/UpcE.php @@ -41,7 +41,7 @@ public static function passes(string $value): bool // GS1's zero-suppression patterns, keyed by the sixth data digit. $body = match (true) { - $selector === '0' || $selector === '1' || $selector === '2' => $numberSystem . substr($data, 0, 2) . $selector . '0000' . substr($data, 2, 3), + in_array($selector, ['0', '1', '2'], true) => $numberSystem . substr($data, 0, 2) . $selector . '0000' . substr($data, 2, 3), $selector === '3' => $numberSystem . substr($data, 0, 3) . '00000' . substr($data, 3, 2), $selector === '4' => $numberSystem . substr($data, 0, 4) . '00000' . $data[4], default => $numberSystem . substr($data, 0, 5) . '0000' . $selector, diff --git a/src/Rules/Encoding/Base64Image.php b/src/Rules/Encoding/Base64Image.php index aa8d7c6..69cd69c 100644 --- a/src/Rules/Encoding/Base64Image.php +++ b/src/Rules/Encoding/Base64Image.php @@ -26,14 +26,14 @@ * * Pure tier — the sniff reads the in-memory buffer; nothing touches disk. */ -final class Base64Image implements ValidationRule +final readonly class Base64Image implements ValidationRule { /** * @param list $mimes Accepted `image/*` subtypes. */ public function __construct( - private readonly array $mimes = ['jpeg', 'png', 'gif', 'webp', 'bmp'], - private readonly ?int $maxBytes = null, + private array $mimes = ['jpeg', 'png', 'gif', 'webp', 'bmp'], + private ?int $maxBytes = null, ) {} public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Rules/Encoding/DataUri.php b/src/Rules/Encoding/DataUri.php index 1bc6010..98af186 100644 --- a/src/Rules/Encoding/DataUri.php +++ b/src/Rules/Encoding/DataUri.php @@ -24,7 +24,7 @@ * * Pure tier — no IO. */ -final class DataUri implements ValidationRule +final readonly class DataUri implements ValidationRule { private const string SHAPE = '/^data:(?[\w!#$&^.+-]+\/[\w!#$&^.+-]+(?:;[\w-]+=[^;,]+)*)?(?;base64)?,(?.*)$/isD'; @@ -34,7 +34,7 @@ final class DataUri implements ValidationRule /** * @param list $mediaTypes Accepted declared types, exact or `family/*`. */ - public function __construct(private readonly array $mediaTypes = []) {} + public function __construct(private array $mediaTypes = []) {} public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/src/Rules/Fiscal/VatNumber.php b/src/Rules/Fiscal/VatNumber.php index 6070adf..03efbd8 100644 --- a/src/Rules/Fiscal/VatNumber.php +++ b/src/Rules/Fiscal/VatNumber.php @@ -24,7 +24,7 @@ * registry) can say a number is issued and active, and that is a network * question this pure-tier rule deliberately does not ask. */ -final class VatNumber implements ValidationRule +final readonly class VatNumber implements ValidationRule { /** National tail formats, keyed by prefix. */ private const array PATTERNS = [ @@ -61,7 +61,7 @@ final class VatNumber implements ValidationRule /** * @param list|null $countries Accepted prefixes; null accepts every known one. */ - public function __construct(private readonly ?array $countries = null) {} + public function __construct(private ?array $countries = null) {} public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -90,20 +90,20 @@ private function passes(string $value): bool } return match ($country) { - 'NL' => self::dutch($normalised, $tail), + 'NL' => $this->dutch($normalised, $tail), 'BE' => (int) substr($tail, 8, 2) === 97 - ((int) substr($tail, 0, 8) % 97), - 'DE' => self::germanMod11x10($tail), - 'IT' => self::italianLuhn($tail), - 'SE' => str_ends_with($tail, '01') && self::luhnPasses(substr($tail, 0, 10)), - 'EL' => self::greekPowerSum($tail), + 'DE' => $this->germanMod11x10($tail), + 'IT' => $this->italianLuhn($tail), + 'SE' => str_ends_with($tail, '01') && $this->luhnPasses(substr($tail, 0, 10)), + 'EL' => $this->greekPowerSum($tail), 'LU' => (int) substr($tail, 6, 2) === (int) substr($tail, 0, 6) % 89, - 'FR' => self::frenchKey($tail), + 'FR' => $this->frenchKey($tail), default => true, }; } /** The 11-proef, or the 2020 sole-proprietor mod-97 form. */ - private static function dutch(string $full, string $tail): bool + private function dutch(string $full, string $tail): bool { $sum = 0; @@ -132,7 +132,7 @@ private static function dutch(string $full, string $tail): bool } /** ISO 7064 MOD 11,10 over the nine digits. */ - private static function germanMod11x10(string $digits): bool + private function germanMod11x10(string $digits): bool { $product = 10; @@ -152,7 +152,7 @@ private static function germanMod11x10(string $digits): bool } /** Odd positions as-is, even doubled with the 9-fold — total mod 10 is zero. */ - private static function italianLuhn(string $digits): bool + private function italianLuhn(string $digits): bool { $sum = 0; @@ -173,7 +173,7 @@ private static function italianLuhn(string $digits): bool return $sum % 10 === 0; } - private static function greekPowerSum(string $digits): bool + private function greekPowerSum(string $digits): bool { $sum = 0; @@ -185,7 +185,7 @@ private static function greekPowerSum(string $digits): bool } /** The numeric control key; keys containing letters pass on format alone. */ - private static function frenchKey(string $tail): bool + private function frenchKey(string $tail): bool { $key = substr($tail, 0, 2); @@ -196,7 +196,7 @@ private static function frenchKey(string $tail): bool return (int) $key === (12 + 3 * ((int) substr($tail, 2) % 97)) % 97; } - private static function luhnPasses(string $digits): bool + private function luhnPasses(string $digits): bool { $sum = 0; $double = false; diff --git a/src/Rules/Identifiers/HashDigest.php b/src/Rules/Identifiers/HashDigest.php index 1dd9b5a..1016e3b 100644 --- a/src/Rules/Identifiers/HashDigest.php +++ b/src/Rules/Identifiers/HashDigest.php @@ -23,7 +23,7 @@ * This proves shape, not provenance: a well-formed digest says nothing about * what was hashed. Pure tier — no IO. */ -final class HashDigest implements ClientCheckable, ValidationRule +final readonly class HashDigest implements ClientCheckable, ValidationRule { /** Hex-digest widths per admitted algorithm. */ private const array LENGTHS = [ @@ -57,7 +57,7 @@ final class HashDigest implements ClientCheckable, ValidationRule 'xxh128' => 32, ]; - private readonly int $length; + private int $length; public function __construct(string $algorithm) { diff --git a/src/Rules/Network/HasGravatar.php b/src/Rules/Network/HasGravatar.php index 0d89c14..7a14655 100644 --- a/src/Rules/Network/HasGravatar.php +++ b/src/Rules/Network/HasGravatar.php @@ -30,11 +30,11 @@ * * Network tier — skipped during precognition. */ -final class HasGravatar implements PrecognitionSkippable, ValidationRule +final readonly class HasGravatar implements PrecognitionSkippable, ValidationRule { use SkipsPrecognition; - public function __construct(private readonly int $timeoutSeconds = 3) {} + public function __construct(private int $timeoutSeconds = 3) {} public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/src/Rules/Network/ImageUrl.php b/src/Rules/Network/ImageUrl.php index 0e016b3..085e848 100644 --- a/src/Rules/Network/ImageUrl.php +++ b/src/Rules/Network/ImageUrl.php @@ -31,7 +31,7 @@ * * Network tier — skipped during precognition. */ -final class ImageUrl implements PrecognitionSkippable, ValidationRule +final readonly class ImageUrl implements PrecognitionSkippable, ValidationRule { use SkipsPrecognition; @@ -41,8 +41,8 @@ final class ImageUrl implements PrecognitionSkippable, ValidationRule * @param list $mimes Accepted `image/*` subtypes; empty accepts any image. */ public function __construct( - private readonly array $mimes = [], - private readonly int $timeoutSeconds = 3, + private array $mimes = [], + private int $timeoutSeconds = 3, ) {} public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Rules/Payment/CardCvc.php b/src/Rules/Payment/CardCvc.php index e796fb6..9b56464 100644 --- a/src/Rules/Payment/CardCvc.php +++ b/src/Rules/Payment/CardCvc.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Arr; use Simtabi\Laranail\Validation\Contracts\Payment\CardBrandCatalogue; +use Simtabi\Laranail\Validation\Support\Payment\CardBrand; /** * A card security code. Alone, 3 or 4 digits; given `numberField:`, the @@ -59,7 +60,7 @@ private function allowedLengths(): array if (is_string($number)) { $brand = $this->catalogue()->identify(str_replace([' ', '-'], '', $number)); - if ($brand !== null) { + if ($brand instanceof CardBrand) { return $brand->cvcLengths; } } diff --git a/src/Rules/Payment/CardExpiry.php b/src/Rules/Payment/CardExpiry.php index c2cf3a0..b685a67 100644 --- a/src/Rules/Payment/CardExpiry.php +++ b/src/Rules/Payment/CardExpiry.php @@ -23,14 +23,14 @@ * * Pure tier — no IO. */ -final class CardExpiry implements ValidationRule +final readonly class CardExpiry implements ValidationRule { private const string SHAPE = '/^(?:(?0?[1-9]|1[0-2])[\/-](?\d{2}|\d{4})|(?\d{4})-(?0[1-9]|1[0-2]))$/D'; public function __construct( - private readonly ?string $timezone = null, - private readonly int $maxYearsAhead = 20, + private ?string $timezone = null, + private int $maxYearsAhead = 20, ) {} public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Rules/Payment/CardNumber.php b/src/Rules/Payment/CardNumber.php index 7c4fff3..0b8037e 100644 --- a/src/Rules/Payment/CardNumber.php +++ b/src/Rules/Payment/CardNumber.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Translation\PotentiallyTranslatedString; use Simtabi\Laranail\Validation\Contracts\Payment\CardBrandCatalogue; +use Simtabi\Laranail\Validation\Support\Payment\CardBrand; /** * A payment card number: brand identified by IIN range (via the @@ -58,7 +59,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $brand = $this->catalogue()->identify($digits); - if ($brand === null) { + if (! $brand instanceof CardBrand) { $this->fail($fail, 'card_number'); return; diff --git a/src/Rules/Storage/FileExistsOnDisk.php b/src/Rules/Storage/FileExistsOnDisk.php index f616d27..64fae30 100644 --- a/src/Rules/Storage/FileExistsOnDisk.php +++ b/src/Rules/Storage/FileExistsOnDisk.php @@ -23,11 +23,11 @@ * cheap; on an S3-style disk this is a network round-trip per validated * value — know which disk the rule points at. */ -final class FileExistsOnDisk implements ValidationRule +final readonly class FileExistsOnDisk implements ValidationRule { public function __construct( - private readonly string $disk, - private readonly string $directory = '', + private string $disk, + private string $directory = '', ) {} public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Rules/Text/HtmlClean.php b/src/Rules/Text/HtmlClean.php index 018fbd4..3dd01ce 100644 --- a/src/Rules/Text/HtmlClean.php +++ b/src/Rules/Text/HtmlClean.php @@ -31,7 +31,7 @@ * * Pure tier — no IO. */ -final class HtmlClean implements ValidationRule +final readonly class HtmlClean implements ValidationRule { /** * `mustContainHtml:` inverts the rule: the value must contain at least @@ -39,7 +39,7 @@ final class HtmlClean implements ValidationRule * the editor failed to load. The same subtleties hold mirrored: encoded * markup and a bare `<` are prose, so they do not satisfy it. */ - public function __construct(private readonly bool $mustContainHtml = false) {} + public function __construct(private bool $mustContainHtml = false) {} public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/src/Rules/Text/MaxWords.php b/src/Rules/Text/MaxWords.php index c97f525..4784bb4 100644 --- a/src/Rules/Text/MaxWords.php +++ b/src/Rules/Text/MaxWords.php @@ -17,9 +17,9 @@ * * Pure tier — no IO. */ -final class MaxWords implements ValidationRule +final readonly class MaxWords implements ValidationRule { - public function __construct(private readonly int $max) + public function __construct(private int $max) { if ($max < 1) { throw new LogicException('MaxWords needs a positive bound — a maximum of zero words is `prohibited`.'); diff --git a/src/Rules/Text/MinWords.php b/src/Rules/Text/MinWords.php index 456bd05..a530f1c 100644 --- a/src/Rules/Text/MinWords.php +++ b/src/Rules/Text/MinWords.php @@ -15,9 +15,9 @@ * * Pure tier — no IO. */ -final class MinWords implements ValidationRule +final readonly class MinWords implements ValidationRule { - public function __construct(private readonly int $min) + public function __construct(private int $min) { if ($min < 1) { throw new LogicException('MinWords needs a positive bound — a minimum of zero words is no rule at all.'); diff --git a/src/Rules/Text/Salutation.php b/src/Rules/Text/Salutation.php index 50925b3..1f264ac 100644 --- a/src/Rules/Text/Salutation.php +++ b/src/Rules/Text/Salutation.php @@ -24,7 +24,7 @@ final class Salutation implements ValidationRule { /** @var array|null */ - private ?array $accepted; + private readonly ?array $accepted; /** @var array|null */ private static ?array $bundled = null; diff --git a/src/Rules/Text/Username.php b/src/Rules/Text/Username.php index 5fd6a94..396a788 100644 --- a/src/Rules/Text/Username.php +++ b/src/Rules/Text/Username.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Simtabi\Laranail\Validation\Contracts\ClientCheckable; +use Simtabi\Laranail\Validation\Contracts\ReservedUsernameList; /** * A username: letters, digits, and single internal separators. @@ -27,6 +28,9 @@ * and `new` collide with the conventional sub-paths of a profile URL. It is a * floor, not a policy — pass `reserved:` to replace it, and keep a product's * own list in the application where it can change without a release here. + * Application-wide policy binds the {@see ReservedUsernameList} contract + * instead — the bundled ExtendedReservedUsernameList is the opt-in wider + * set; `reserved:` on one rule instance still wins over the binding. * * Matching is case-insensitive and runs against the value with its separators * stripped, because `a-d-m-i-n` and `ad.min` are the same claim. @@ -77,7 +81,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - if (self::isReserved($value, $this->reserved ?? self::DEFAULT_RESERVED, $this->separators)) { + if (self::isReserved($value, $this->reserved ?? $this->boundReserved(), $this->separators)) { $fail('laranail-validation::validation.username_reserved')->translate(); } } @@ -102,6 +106,23 @@ public static function passes( return ! self::isReserved($value, $reserved ?? self::DEFAULT_RESERVED, $separators); } + /** + * The application-bound list, or the floor when no container is booted — + * a rule set built in a queued job or plain unit test still validates. + * Uncached on purpose: this class is readonly, and the container binding + * may legitimately change between validations in long-lived workers. + * + * @return list + */ + private function boundReserved(): array + { + if (function_exists('app') && app()->bound(ReservedUsernameList::class)) { + return resolve(ReservedUsernameList::class)->names(); + } + + return self::DEFAULT_RESERVED; + } + /** * Shape and length in one pattern, so the rule and the form advertised to * a browser cannot disagree. diff --git a/src/Support/Text/DefaultReservedUsernameList.php b/src/Support/Text/DefaultReservedUsernameList.php new file mode 100644 index 0000000..f3f346f --- /dev/null +++ b/src/Support/Text/DefaultReservedUsernameList.php @@ -0,0 +1,19 @@ +app->singleton(ReservedUsernameList::class, ExtendedReservedUsernameList::class); + */ +final class ExtendedReservedUsernameList implements ReservedUsernameList +{ + /** @var list|null */ + private ?array $names = null; + + public function names(): array + { + return $this->names ??= array_keys( + CodeFile::load(dirname(__DIR__, 3) . '/resources/data/reserved-usernames.txt'), + ); + } +} diff --git a/src/ValidationServiceProvider.php b/src/ValidationServiceProvider.php index 8c5f1f1..2a0018a 100644 --- a/src/ValidationServiceProvider.php +++ b/src/ValidationServiceProvider.php @@ -19,6 +19,7 @@ use Simtabi\Laranail\Validation\Contracts\I18n\CurrencyDataset; use Simtabi\Laranail\Validation\Contracts\I18n\LanguageDataset; use Simtabi\Laranail\Validation\Contracts\Payment\CardBrandCatalogue; +use Simtabi\Laranail\Validation\Contracts\ReservedUsernameList; use Simtabi\Laranail\Validation\Support\Email\BundledDisposableDomainList; use Simtabi\Laranail\Validation\Support\Email\BundledRoleAccountList; use Simtabi\Laranail\Validation\Support\I18n\BundledCountryDataset; @@ -27,6 +28,7 @@ use Simtabi\Laranail\Validation\Support\Payment\BundledCardBrandCatalogue; use Simtabi\Laranail\Validation\Support\RuleAliases; use Simtabi\Laranail\Validation\Support\RuleRegistrar; +use Simtabi\Laranail\Validation\Support\Text\DefaultReservedUsernameList; use Stringable; /** @@ -106,6 +108,7 @@ private function bindI18nDatasetFallbacks(): void $this->app->singletonIf(CurrencyDataset::class, BundledCurrencyDataset::class); $this->app->singletonIf(LanguageDataset::class, BundledLanguageDataset::class); $this->app->singletonIf(CardBrandCatalogue::class, BundledCardBrandCatalogue::class); + $this->app->singletonIf(ReservedUsernameList::class, DefaultReservedUsernameList::class); } public function bootingPackage(): void diff --git a/tests/Rules/Chrono/ChronoRulesTest.php b/tests/Rules/Chrono/ChronoRulesTest.php index f5ee969..a929295 100644 --- a/tests/Rules/Chrono/ChronoRulesTest.php +++ b/tests/Rules/Chrono/ChronoRulesTest.php @@ -1,6 +1,6 @@ toBeTrue() ->and(ruleAccepts(new MinimumAge(18), '2008-08-24'))->toBeTrue() // 18 today @@ -199,7 +199,7 @@ it('answers in the timezone it is told to', function (): void { // 20:00 UTC on the 23rd is already the 24th in Auckland: the same // person is 17 in London and 18 at home. - Carbon::setTestNow('2026-08-23 20:00:00'); + Date::setTestNow('2026-08-23 20:00:00'); expect(ruleAccepts(new MinimumAge(18, timezone: 'UTC'), '2008-08-24'))->toBeFalse() ->and(ruleAccepts(new MinimumAge(18, timezone: 'Pacific/Auckland'), '2008-08-24'))->toBeTrue(); diff --git a/tests/Rules/Encoding/EncodingRulesTest.php b/tests/Rules/Encoding/EncodingRulesTest.php index 736411d..8ed1b38 100644 --- a/tests/Rules/Encoding/EncodingRulesTest.php +++ b/tests/Rules/Encoding/EncodingRulesTest.php @@ -61,10 +61,10 @@ ->and($file->getSize())->toBe(70) ->and(Validator::make( ['f' => $file], - ['f' => 'image'], - )->passes())->toBeTrue(); - - expect(Base64File::toUploadedFile('not base64'))->toBeNull(); + ['f' => ['image']], + )->passes())->toBeTrue() + ->and(Base64File::toUploadedFile('not base64')) + ->toBeNull(); }); // ========================================================================= diff --git a/tests/Rules/I18n/I18nRulesTest.php b/tests/Rules/I18n/I18nRulesTest.php index 9270233..e56dfd5 100644 --- a/tests/Rules/I18n/I18nRulesTest.php +++ b/tests/Rules/I18n/I18nRulesTest.php @@ -148,8 +148,8 @@ public function isSymbol(string $symbol): bool // ========================================================================= it('keeps the generated datasets in step with their committed sources', function (): void { - $php = (string) (new PhpExecutableFinder())->find(); - expect($php)->not->toBe(''); + $php = (string) new PhpExecutableFinder()->find(); + expect($php)->not->toBeEmpty(); $process = new Process( [$php, dirname(__DIR__, 3) . '/tools/build-datasets.php', '--check'], diff --git a/tests/Rules/Payment/PaymentRulesTest.php b/tests/Rules/Payment/PaymentRulesTest.php index 39fe5c3..beb1e40 100644 --- a/tests/Rules/Payment/PaymentRulesTest.php +++ b/tests/Rules/Payment/PaymentRulesTest.php @@ -1,6 +1,6 @@ app->instance(CardBrandCatalogue::class, new class implements CardBrandCatalogue { + /** @return list */ public function brands(): array { return [$this->store()]; @@ -130,13 +131,13 @@ private function store(): CardBrand // ========================================================================= it('accepts current and future expiries in the common spellings', function (string $value): void { - Carbon::setTestNow('2026-08-24 12:00:00'); + Date::setTestNow('2026-08-24 12:00:00'); expect(ruleAccepts(new CardExpiry(), $value))->toBeTrue(); })->with(['08/26', '09/26', '12/2027', '08-26', '11-2030', '2027-03', '8/27']); it('rejects past, malformed and implausibly distant expiries', function (mixed $value): void { - Carbon::setTestNow('2026-08-24 12:00:00'); + Date::setTestNow('2026-08-24 12:00:00'); expect(ruleAccepts(new CardExpiry(), $value))->toBeFalse(); })->with([ @@ -153,7 +154,7 @@ private function store(): CardBrand it('treats the current month as valid until it ends, in the given timezone', function (): void { // 23:30 UTC on Aug 31 is already September in Nairobi. - Carbon::setTestNow('2026-08-31 23:30:00'); + Date::setTestNow('2026-08-31 23:30:00'); expect(ruleAccepts(new CardExpiry(timezone: 'UTC'), '08/26'))->toBeTrue() ->and(ruleAccepts(new CardExpiry(timezone: 'Africa/Nairobi'), '08/26'))->toBeFalse(); diff --git a/tests/Rules/Text/ReservedUsernameListTest.php b/tests/Rules/Text/ReservedUsernameListTest.php new file mode 100644 index 0000000..2ff70ac --- /dev/null +++ b/tests/Rules/Text/ReservedUsernameListTest.php @@ -0,0 +1,38 @@ +toBeTrue() + ->and(ruleAccepts(new Username(), 'admin'))->toBeFalse(); +}); + +it('honours a bound list, including separator-stripped claims', function (): void { + $this->app->singleton(ReservedUsernameList::class, ExtendedReservedUsernameList::class); + + expect(ruleAccepts(new Username(), 'gitlab'))->toBeFalse() + ->and(ruleAccepts(new Username(), 'git.lab'))->toBeFalse() + ->and(ruleAccepts(new Username(), 'imani'))->toBeTrue(); +}); + +it('lets an explicit reserved: list win over the binding', function (): void { + $this->app->singleton(ReservedUsernameList::class, ExtendedReservedUsernameList::class); + + expect(ruleAccepts(new Username(reserved: ['imani']), 'gitlab'))->toBeTrue() + ->and(ruleAccepts(new Username(reserved: ['imani']), 'imani'))->toBeFalse(); +}); + +it('ships the extended set without the profanity entries', function (): void { + $names = new ExtendedReservedUsernameList()->names(); + + // The archive's list mixed reserved names with profanity; what counts + // as offensive is TermList policy, so none of it ships here. + expect(count($names))->toBeGreaterThan(300) + ->and($names)->toContain('admin', 'api', 'www', 'gitlab', 'microsoft') + ->not->toContain('shit', 'porn', 'rnail', 'cnarne'); +});