Skip to content

Commit 1a0c743

Browse files
committed
fix(validator): normalize malformed UTF-8 param keys during JSON serialization
- Apply utf8Safe() to param keys in ValidationError::jsonSerialize() - Document key normalization in CHANGELOG, error-handling guide, and llms.txt - Add test for invalid UTF-8 in a satisfies() param key
1 parent b338da9 commit 1a0c743

5 files changed

Lines changed: 23 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file.
66

77
### Added
88

9-
- Structured error model. Validation errors are now a flat list of new `ValidationError` value objects, each with `getPath()` (dotted, `''` at root), `getCode()` (a stable code from the new `ValidationCode` catalog, e.g. `STRING_TOO_SHORT`, `INVALID_TYPE`, `REQUIRED`), `getMessage()`, and `getParams()` (e.g. `['min' => 5]`). `ValidationError` is `JsonSerializable` (`{path, code, message, params}`), so `json_encode($e->getErrors())` is API-ready — a param value that cannot be encoded (a resource, `NAN`/`INF`, or an object whose `jsonSerialize()` throws) renders as `(complex value)` rather than failing serialization. All built-in validators now emit codes and params; messages support `{name}` placeholder substitution from params
9+
- Structured error model. Validation errors are now a flat list of new `ValidationError` value objects, each with `getPath()` (dotted, `''` at root), `getCode()` (a stable code from the new `ValidationCode` catalog, e.g. `STRING_TOO_SHORT`, `INVALID_TYPE`, `REQUIRED`), `getMessage()`, and `getParams()` (e.g. `['min' => 5]`). `ValidationError` is `JsonSerializable` (`{path, code, message, params}`), so `json_encode($e->getErrors())` is API-ready — malformed UTF-8 param keys are normalized, and a param value that cannot be encoded (a resource, `NAN`/`INF`, or an object whose `jsonSerialize()` throws) renders as `(complex value)` rather than failing serialization. All built-in validators now emit codes and params; messages support `{name}` placeholder substitution from params
1010
- `ValidationException::getErrors(?string $path = null)` filters to a single field: with no argument it returns every error; with a path it returns errors at that path and everything nested beneath it (`getErrors('')` returns only root-level errors; the segment-aware match means `getErrors('name')` won't catch `name_full`)
1111
- `satisfies()` accepts two optional trailing arguments: `?string $code` (defaults to `CUSTOM`) and `array $params` for structured error codes and message placeholders on custom rules
1212
- `enum()` on `FieldValidator` now accepts PHP `UnitEnum` (non-backed enums): the value must be an instance of the enum or a string equal to one of the case names; `BackedEnum` behavior is unchanged (int or string backed values via `tryFrom()`)

docs/guides/error-handling.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,9 @@ JSON output example:
280280
The same works from a `tryValidate()` tuple — its third element is the same `ValidationError[]`, so
281281
`json_encode($errors)` produces identical output.
282282

283-
Serialization is safe by construction: if a param value cannot be JSON-encoded (a resource,
284-
`NAN`/`INF`, or an object whose `jsonSerialize()` throws), it renders as `"(complex value)"` rather
285-
than making the response fail.
283+
Serialization is safe by construction: malformed UTF-8 param keys are normalized, and if a param
284+
value cannot be JSON-encoded (a resource, `NAN`/`INF`, or an object whose `jsonSerialize()` throws),
285+
it renders as `"(complex value)"` rather than making the response fail.
286286

287287
## Fail-Fast Per Field
288288

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
- `validate(mixed $value, string $key = '', mixed $input = null)` throws `ValidationException`.
2626
- `tryValidate(mixed $value, string $key = '', mixed $input = null)` returns tuple `[bool $valid, mixed $data, ?array $errors]`, where `$errors` is a flat list of `ValidationError` objects (or `null` on success).
2727
- **Fail-Fast:** Each validator chain stops at the first failing rule; schema validators aggregate errors across fields.
28-
- **Error Structure:** Validation errors are a flat list of `ValidationError` value objects, each with `getPath()` (dotted, `''` at root), `getCode()` (stable code, see `ValidationCode`), `getMessage()`, and `getParams()`. Access via `ValidationException::getErrors(?string $path = null)` or the `tryValidate` tuple. `getErrors()` (no arg) returns every error; `getErrors($path)` filters to a field and everything nested beneath it (`getErrors('')` returns only root-level errors; the trailing-dot match means `getErrors('name')` won't catch `name_full`). Match on codes, not message text. `satisfies()` accepts optional `(?string $code, array $params)`; params double as `{name}` message placeholders. `ValidationError` is `JsonSerializable` (`{path, code, message, params}`), so `json_encode($e->getErrors())` is API-ready.
28+
- **Error Structure:** Validation errors are a flat list of `ValidationError` value objects, each with `getPath()` (dotted, `''` at root), `getCode()` (stable code, see `ValidationCode`), `getMessage()`, and `getParams()`. Access via `ValidationException::getErrors(?string $path = null)` or the `tryValidate` tuple. `getErrors()` (no arg) returns every error; `getErrors($path)` filters to a field and everything nested beneath it (`getErrors('')` returns only root-level errors; the trailing-dot match means `getErrors('name')` won't catch `name_full`). Match on codes, not message text. `satisfies()` accepts optional `(?string $code, array $params)`; params double as `{name}` message placeholders. `ValidationError` is `JsonSerializable` (`{path, code, message, params}`), so `json_encode($e->getErrors())` is API-ready; malformed UTF-8 param keys are normalized during serialization, and unencodable param values render as `"(complex value)"`.
2929
- **Null Handling:** Validators skip `null` values unless `required()` is called. `default()` fills in null as a last-resort fallback after the pipeline; `required()` enforces presence at the very end.
3030
- **Pipeline:** Supports both validation (`satisfies`) and transformation (`transform`, `pipe`). Flow: coerce -> type check -> pipeline -> default -> required.
3131
- **Coercion:** `coerce()` enables smart type conversion (e.g., "true" -> `true`). Non-coercible values pass through unchanged.

src/Lemmon/Validator/ValidationError.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,11 @@ public static function interpolate(string $template, array $params): string
9999

100100
/**
101101
* Serializes to the public error shape. The string fields are already valid UTF-8 (normalised
102-
* in the constructor) and each param value is passed through {@see jsonSafe()}, so json_encode()
103-
* on a ValidationError -- the advertised API payload -- can never throw or return false, whatever
104-
* value a rule put in the error.
102+
* in the constructor); each param value is passed through {@see jsonSafe()} and each param key
103+
* through {@see utf8Safe()} (a custom satisfies() call can supply a key with malformed UTF-8,
104+
* which the surrounding object cast would otherwise carry through and make json_encode() fail).
105+
* So json_encode() on a ValidationError -- the advertised API payload -- can never throw or
106+
* return false, whatever key or value a rule put in the error.
105107
*
106108
* Params are an object (cast from the string-keyed array) so the JSON shape stays
107109
* predictable -- an empty params map is `{}`, never `[]`.
@@ -110,11 +112,16 @@ public static function interpolate(string $template, array $params): string
110112
*/
111113
public function jsonSerialize(): array
112114
{
115+
$params = [];
116+
foreach ($this->params as $name => $value) {
117+
$params[self::utf8Safe((string) $name)] = self::jsonSafe($value);
118+
}
119+
113120
return [
114121
'path' => $this->path,
115122
'code' => $this->code,
116123
'message' => $this->message,
117-
'params' => (object) array_map(self::jsonSafe(...), $this->params),
124+
'params' => (object) $params,
118125
];
119126
}
120127

tests/StructuredErrorsTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,13 @@ public function jsonSerialize(): mixed
361361
->tryValidate('whatever')[2] ?? []
362362
),
363363
],
364+
'invalid UTF-8 in a satisfies() param key' => [
365+
fn() => (
366+
Validator::isString()
367+
->satisfies(static fn() => false, 'nope', 'X', ["bad \xC3\x28 key" => 5])
368+
->tryValidate('whatever')[2] ?? []
369+
),
370+
],
364371
'invalid UTF-8 in a custom message' => [
365372
fn() => (
366373
Validator::isString()

0 commit comments

Comments
 (0)