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/CHANGELOG.md b/CHANGELOG.md index 377fd18..36e91f9 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\`: 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 [the reference](docs/tools/rule-library.md). @@ -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/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/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/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. diff --git a/docs/tools/rule-library.md b/docs/tools/rule-library.md index 6428bda..2cd590e 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 @@ -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 @@ -87,14 +91,65 @@ 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. + +## 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 @@ -155,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: @@ -173,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. @@ -201,11 +266,36 @@ 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 | |---|---|---|---| | `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 @@ -218,6 +308,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 @@ -238,6 +329,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 | @@ -254,8 +354,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 @@ -263,6 +392,7 @@ owns that dataset. |---|---|---|---| | `Imei` | — | `imei` | `imei` | | `Vin` | `bool $checkDigit = false` | `vin` | `vin` | +| `HashDigest` | `string $algorithm` | `hash_digest` | `hash_digest` | | `SemVer` | — | `semver` | `semver` | | `Jwt` | — | `jwt` | `jwt` | @@ -272,6 +402,11 @@ owns that dataset. 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. @@ -353,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` @@ -382,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 | @@ -442,6 +604,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 | @@ -522,8 +714,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`; @@ -536,6 +731,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. @@ -616,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/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/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/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 fe77145..d13a05f 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -16,17 +16,22 @@ // 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.', // 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 + '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.', @@ -97,10 +102,53 @@ '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.', + // 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.', + '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.', + '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.', + '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.', + '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.', @@ -121,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.', @@ -167,6 +216,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/Commands/RulesCommand.php b/src/Commands/RulesCommand.php index b9f6c6f..9502a23 100644 --- a/src/Commands/RulesCommand.php +++ b/src/Commands/RulesCommand.php @@ -67,6 +67,14 @@ 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'], + 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'], + 'file_exists_on_disk' => ['local'], + 'hash_digest' => ['sha256'], + 'max_words' => ['200'], + 'min_words' => ['2'], ]; $index = []; 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 @@ + */ + 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/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/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/Chrono/DateInterval.php b/src/Rules/Chrono/DateInterval.php new file mode 100644 index 0000000..fab57ca --- /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..6993326 --- /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) ? $this->parse($value) : 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]); + } + } + + 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) ? $this->parse($sibling) : null; + } + + return $this->parse($this->from); + } + + private 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..9074e8c --- /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..c6b5e25 --- /dev/null +++ b/src/Rules/Chrono/MinuteIn.php @@ -0,0 +1,60 @@ + $minutes Allowed minute values, each 0–59. + */ + public function __construct(private 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..2a7e508 --- /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..e0081e9 --- /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/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..7b9c2bd --- /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) { + 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, + }; + + return Gtin::passes($body . $value[7], [12]); + } +} 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/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..69cd69c --- /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 readonly class Base64Image implements ValidationRule +{ + /** + * @param list $mimes Accepted `image/*` subtypes. + */ + public function __construct( + private array $mimes = ['jpeg', 'png', 'gif', 'webp', 'bmp'], + private ?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..98af186 --- /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 readonly 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 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/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..03efbd8 --- /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 ?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' => $this->dutch($normalised, $tail), + 'BE' => (int) substr($tail, 8, 2) === 97 - ((int) substr($tail, 0, 8) % 97), + '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' => $this->frenchKey($tail), + default => true, + }; + } + + /** The 11-proef, or the 2020 sole-proprietor mod-97 form. */ + private 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 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 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 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 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 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/Rules/I18n/CountryCode.php b/src/Rules/I18n/CountryCode.php new file mode 100644 index 0000000..e1c656a --- /dev/null +++ b/src/Rules/I18n/CountryCode.php @@ -0,0 +1,55 @@ +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/Rules/Identifiers/HashDigest.php b/src/Rules/Identifiers/HashDigest.php new file mode 100644 index 0000000..1016e3b --- /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 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/Rules/Network/HasGravatar.php b/src/Rules/Network/HasGravatar.php new file mode 100644 index 0000000..7a14655 --- /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..085e848 --- /dev/null +++ b/src/Rules/Network/ImageUrl.php @@ -0,0 +1,100 @@ + $mimes Accepted `image/*` subtypes; empty accepts any image. + */ + public function __construct( + private array $mimes = [], + private 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/Payment/CardCvc.php b/src/Rules/Payment/CardCvc.php new file mode 100644 index 0000000..9b56464 --- /dev/null +++ b/src/Rules/Payment/CardCvc.php @@ -0,0 +1,76 @@ + */ + 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 instanceof CardBrand) { + 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..b685a67 --- /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 ?string $timezone = null, + private 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..0b8037e --- /dev/null +++ b/src/Rules/Payment/CardNumber.php @@ -0,0 +1,124 @@ +|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 instanceof CardBrand) { + $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/Rules/Storage/FileExistsOnDisk.php b/src/Rules/Storage/FileExistsOnDisk.php new file mode 100644 index 0000000..64fae30 --- /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/Rules/Text/HtmlClean.php b/src/Rules/Text/HtmlClean.php index c767676..3dd01ce 100644 --- a/src/Rules/Text/HtmlClean.php +++ b/src/Rules/Text/HtmlClean.php @@ -31,10 +31,26 @@ * * 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 + * 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 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..4784bb4 --- /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..a530f1c --- /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..1f264ac --- /dev/null +++ b/src/Rules/Text/Salutation.php @@ -0,0 +1,60 @@ +|null */ + private readonly ?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/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/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/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/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 e31de18..36c1100 100644 --- a/src/Support/RuleAliases.php +++ b/src/Support/RuleAliases.php @@ -10,29 +10,51 @@ 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; +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; 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; 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; 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\Fiscal\VatNumber; use Simtabi\Laranail\Validation\Rules\Geo\CaProvince; use Simtabi\Laranail\Validation\Rules\Geo\Latitude; 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\HashDigest; use Simtabi\Laranail\Validation\Rules\Identifiers\Imei; use Simtabi\Laranail\Validation\Rules\Identifiers\Jwt; use Simtabi\Laranail\Validation\Rules\Identifiers\SemVer; @@ -47,14 +69,23 @@ 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; +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\Storage\FileExistsOnDisk; 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; @@ -117,6 +148,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, ]; /** @@ -130,18 +167,23 @@ 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(), '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(), '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(), @@ -154,6 +196,52 @@ 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. + '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 + // 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(), + + // 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(), @@ -168,7 +256,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)), @@ -181,6 +273,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/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 ffe4702..2a0018a 100644 --- a/src/ValidationServiceProvider.php +++ b/src/ValidationServiceProvider.php @@ -15,10 +15,20 @@ 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\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; +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 Simtabi\Laranail\Validation\Support\Text\DefaultReservedUsernameList; use Stringable; /** @@ -61,6 +71,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 +97,20 @@ 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); + $this->app->singletonIf(CardBrandCatalogue::class, BundledCardBrandCatalogue::class); + $this->app->singletonIf(ReservedUsernameList::class, DefaultReservedUsernameList::class); + } + public function bootingPackage(): void { $this->applyBatchLimit(); diff --git a/tests/ConsoleCommandsTest.php b/tests/ConsoleCommandsTest.php index 5eff336..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('54 rules') + ->expectsOutputToContain('84 rules') ->assertSuccessful(); }); diff --git a/tests/RuleAliasesTest.php b/tests/RuleAliasesTest.php index dda877e..5111330 100644 --- a/tests/RuleAliasesTest.php +++ b/tests/RuleAliasesTest.php @@ -34,6 +34,14 @@ 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'], + '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 f285f32..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(54); + ->and(count($classes))->toBe(84); }); 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/Chrono/ChronoRulesTest.php b/tests/Rules/Chrono/ChronoRulesTest.php new file mode 100644 index 0000000..a929295 --- /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 { + Date::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. + 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/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/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, +]); 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/Encoding/EncodingRulesTest.php b/tests/Rules/Encoding/EncodingRulesTest.php new file mode 100644 index 0000000..8ed1b38 --- /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() + ->and(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(); +}); 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, +]); diff --git a/tests/Rules/I18n/I18nRulesTest.php b/tests/Rules/I18n/I18nRulesTest.php new file mode 100644 index 0000000..e56dfd5 --- /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->toBeEmpty(); + + $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/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); +}); 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/Payment/PaymentRulesTest.php b/tests/Rules/Payment/PaymentRulesTest.php new file mode 100644 index 0000000..beb1e40 --- /dev/null +++ b/tests/Rules/Payment/PaymentRulesTest.php @@ -0,0 +1,161 @@ +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 { + /** @return list */ + 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 { + 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 { + Date::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. + 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/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, +]); 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'); +}); 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(); +}); 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