From f08dfc84a0c22076827765c73f19b998234cc408 Mon Sep 17 00:00:00 2001 From: Abdalrhman Emad Saad <80687771+kettasoft@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:55:32 +0200 Subject: [PATCH 1/2] refactor(sanitization): overhaul sanitizer core with built-in defaults, aliases & pipe-chaining (#50) * refactor(sanitization): remove HandlerFactory and streamline sanitizer handling in ArrayHandler and Sanitizer No behaviour change; all existing tests pass * feat: add default sanitizers for various data types including Boolean, Clamp, EscapeHtml, Float, Integer, Lowercase, NullIfEmpty, Slug, StripSpecialChars, StripTags, Trim, and Uppercase * feat(sanitization): enhance sanitizer alias management and add new sanitizers * refactor(tests): replace HandlerFactory with Sanitizer in SanitizeUnitTest * test(sanitization): add comprehensive tests for various sanitizers including Trim, Lowercase, Uppercase, StripTags, EscapeHtml, Integer, Float, Boolean, Slug, NullIfEmpty, Clamp, and StripSpecialChars * refactor(tests): remove TrimSanitizer class and update usage in SanitizeUnitTest * feat(sanitization): enhance handle method to support global and field-specific sanitizers * test(sanitization): add tests for global sanitizers and their application order * docs(sanitization): update sanitization documentation with new structure, examples, and additional sanitizers --- docs/sanitization.md | 353 ++++++++++++-- .../Defaults/BooleanSanitizer.php | 52 ++ src/Sanitization/Defaults/ClampSanitizer.php | 67 +++ .../Defaults/EscapeHtmlSanitizer.php | 47 ++ src/Sanitization/Defaults/FloatSanitizer.php | 45 ++ .../Defaults/IntegerSanitizer.php | 47 ++ .../Defaults/LowercaseSanitizer.php | 33 ++ .../Defaults/NullIfEmptySanitizer.php | 51 ++ src/Sanitization/Defaults/SlugSanitizer.php | 52 ++ .../Defaults/StripSpecialCharsSanitizer.php | 63 +++ .../Defaults/StripTagsSanitizer.php | 44 ++ src/Sanitization/Defaults/TrimSanitizer.php | 44 ++ .../Defaults/UppercaseSanitizer.php | 33 ++ src/Sanitization/HandlerFactory.php | 41 -- src/Sanitization/Handlers/ArrayHandler.php | 4 +- src/Sanitization/Sanitizer.php | 139 +++++- tests/Unit/Sanitization/SanitizeUnitTest.php | 94 +++- .../Sanitization/SanitizerDefaultsTest.php | 444 ++++++++++++++++++ tests/Unit/Sanitization/TrimSanitizer.php | 13 - 19 files changed, 1554 insertions(+), 112 deletions(-) create mode 100644 src/Sanitization/Defaults/BooleanSanitizer.php create mode 100644 src/Sanitization/Defaults/ClampSanitizer.php create mode 100644 src/Sanitization/Defaults/EscapeHtmlSanitizer.php create mode 100644 src/Sanitization/Defaults/FloatSanitizer.php create mode 100644 src/Sanitization/Defaults/IntegerSanitizer.php create mode 100644 src/Sanitization/Defaults/LowercaseSanitizer.php create mode 100644 src/Sanitization/Defaults/NullIfEmptySanitizer.php create mode 100644 src/Sanitization/Defaults/SlugSanitizer.php create mode 100644 src/Sanitization/Defaults/StripSpecialCharsSanitizer.php create mode 100644 src/Sanitization/Defaults/StripTagsSanitizer.php create mode 100644 src/Sanitization/Defaults/TrimSanitizer.php create mode 100644 src/Sanitization/Defaults/UppercaseSanitizer.php delete mode 100644 src/Sanitization/HandlerFactory.php create mode 100644 tests/Unit/Sanitization/SanitizerDefaultsTest.php delete mode 100644 tests/Unit/Sanitization/TrimSanitizer.php diff --git a/docs/sanitization.md b/docs/sanitization.md index 77b93ced..018d2c68 100644 --- a/docs/sanitization.md +++ b/docs/sanitization.md @@ -1,100 +1,369 @@ -# Request Sanitization +--- +title: Request Sanitization +description: Learn how to clean and transform incoming request data using the powerful sanitization features of the Filterable package. +tags: + - sanitization + - cleaning + - transformation + - normalization +--- -Sanitization allows you to clean or transform incomming request data **before** validation or filtering is applied. +Sanitization allows you to clean or transform incoming request data **before** validation or filtering is applied. This feature ensures your filters always work with clean and normalized data. +--- + ## Overview -To enable sanitization in your filter class, -define a `protected $sanitizers` property. -Each entry in this array maps a **request key** to one or more sanitizer classes. +To enable sanitization in your filter class, define a `protected $sanitizers` property. +Each entry maps a **request key** to one or more sanitizers, which can be: + +- **Aliases** (e.g., `'trim'`, `'lowercase'`) +- **Pipe-separated strings** (e.g., `'trim|lowercase|slug'`) +- **Class names** (e.g., `TrimSanitizer::class`) +- **Closures** (e.g., `fn($value) => strtolower($value)`) +- **Arrays** of any of the above +- **Instantiated objects** implementing `Sanitizable` + +--- -## Basic Example +## Quick Start + +### Using Built-in Aliases ```php +use Kettasoft\Filterable\Filterable; + class PostFilter extends Filterable { protected $sanitizers = [ - 'title' => TitleSanitizer::class + 'title' => 'trim|lowercase', + 'price' => 'float', + 'status' => 'boolean', + 'slug' => 'trim|slug' ]; // ... } ``` -In this example, `TitleSanitizer` will be applied to the title field of the request before validation or filtering. +--- + +## Built-in Sanitizers + +The package includes 12 ready-to-use sanitizers accessible via **short aliases**: + +| Alias | Class | Description | +| --------------- | ---------------------------- | -------------------------------------------- | +| `trim` | `TrimSanitizer` | Removes leading/trailing whitespace | +| `lowercase` | `LowercaseSanitizer` | Converts to lowercase (multibyte-safe) | +| `uppercase` | `UppercaseSanitizer` | Converts to uppercase (multibyte-safe) | +| `integer` | `IntegerSanitizer` | Casts value to integer | +| `float` | `FloatSanitizer` | Casts value to float | +| `boolean` | `BooleanSanitizer` | Converts truthy/falsy values to boolean | +| `slug` | `SlugSanitizer` | Converts to URL-friendly slug | +| `strip_tags` | `StripTagsSanitizer` | Removes HTML/PHP tags | +| `strip_chars` | `StripSpecialCharsSanitizer` | Removes special characters | +| `escape_html` | `EscapeHtmlSanitizer` | Escapes HTML special characters | +| `null_if_empty` | `NullIfEmptySanitizer` | Converts empty strings to `null` | +| `clamp` | `ClampSanitizer` | Clamps numeric values between min/max bounds | -## Creating a Sanitizer Class +--- -A sanitizer class must implement a `Sanitizable` interface +## Usage Patterns + +### 1. Using Aliases (Recommended) + +The simplest and most readable approach: ```php -class TitleSanitizer implement Sanitizable -{ - public function sanitize(mixed $value) - { - return is_string($value) ? trim($value) : $value; - } -} +protected $sanitizers = [ + 'email' => 'trim|lowercase', + 'name' => 'trim|uppercase', + 'age' => 'integer', + 'bio' => 'strip_tags|trim' +]; ``` -## Multiple Sanitizers Per field - -You can apply multiple sanitizers to the same field by using array: +### 2. Using Class Names ```php +use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\LowercaseSanitizer; + protected $sanitizers = [ - 'title' => [ + 'email' => [ TrimSanitizer::class, - CapitalizeSanitizer::class + LowercaseSanitizer::class ] ]; ``` -Sanitizers are applied **in the order defined**. +### 3. Using Closures + +For inline transformations: + +```php +protected $sanitizers = [ + 'phone' => fn($value) => preg_replace('/[^0-9]/', '', $value), + 'username' => fn($value) => strtolower(trim($value)) +]; +``` + +### 4. Using Instantiated Objects + +Useful when you need to pass constructor parameters: + +```php +use Kettasoft\Filterable\Sanitization\Defaults\ClampSanitizer; + +protected $sanitizers = [ + 'per_page' => new ClampSanitizer(min: 1, max: 100), + 'discount' => new ClampSanitizer(min: 0, max: 100) +]; +``` + +### 5. Mixing Approaches + +You can combine different approaches: + +```php +protected $sanitizers = [ + 'title' => 'trim|strip_tags', + 'slug' => [ + 'trim', + 'lowercase', + fn($value) => str_replace(' ', '-', $value) + ], + 'price' => new ClampSanitizer(min: 0) +]; +``` + +--- ## Global Sanitizers -You may apply a sanitizer globally to all request inputs by specifying the class **without a key**: +Apply a sanitizer to **all request inputs** by specifying it without a key (using numeric array indexes): + +```php +protected $sanitizers = [ + 'trim', // Global: applies to all fields + 'name' => 'lowercase', + 'email' => 'lowercase' +]; +``` + +You can also use multiple global sanitizers: + +```php +protected $sanitizers = [ + TrimSanitizer::class, // Global #1 + fn($v) => strtolower($v), // Global #2 + 'slug' => 'slug' // Field-specific +]; +``` + +::: tip Execution Order +Global sanitizers run **before** field-specific sanitizers. This ensures all fields are cleaned globally first, then processed individually. + +**Example:** ```php protected $sanitizers = [ - TrimSanitizer::class, // will apply to all keys + 'trim', // Step 1: trim all fields + 'email' => 'lowercase' // Step 2: lowercase only email ]; + +// Input: ' ADMIN@EXAMPLE.COM ' +// After global (trim): 'ADMIN@EXAMPLE.COM' +// After field-specific (lowercase): 'admin@example.com' ``` -::: tip Note -Global sanitizers will run **before** field-specific sanitizers. ::: +--- + +## Creating Custom Sanitizers + +### Implement the `Sanitizable` Interface + +```php + TitleSanitizer::class + ]; +} +``` + +--- + +## Registering Custom Aliases + +You can register your own aliases globally using `Sanitizer::extend()`: + +```php +use Kettasoft\Filterable\Sanitization\Sanitizer; +use App\Sanitizers\TitleSanitizer; + +// In a service provider's boot() method: +Sanitizer::extend('title', TitleSanitizer::class); +``` + +Now you can use the alias anywhere: + +```php +protected $sanitizers = [ + 'post_title' => 'trim|title', + 'page_title' => 'title' +]; +``` + +--- + ## Execution Lifecycle -1. **Global sanitizers** (apply to all keys) -2. Field-specific sanitizers (per key, in array order) -3. Validation -4. Authorization -5. Filtering +Sanitization happens early in the filter execution pipeline: + +1. **Authorization** +2. **Validation** +3. **Global sanitizers** (applied to all keys) +4. **Field-specific sanitizers** (per key, in order) +5. **Filtering** -## Example Scenario +This ensures all downstream processes work with clean, normalized data. + +--- + +## Advanced Examples + +### Example 1: E-commerce Product Filter ```php +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Sanitization\Defaults\ClampSanitizer; + class ProductFilter extends Filterable { protected $sanitizers = [ - TrimSanitizer::class, - 'name' => [ - StripTagsSanitizer::class, - CapitalizeSanitizer::class - ] + 'trim', // global: trim all inputs + + 'name' => 'strip_tags|trim', + 'slug' => 'lowercase|slug', + 'price' => 'float', + 'stock' => 'integer', + 'is_active' => 'boolean', + 'discount' => new ClampSanitizer(min: 0, max: 100), + 'per_page' => new ClampSanitizer(min: 1, max: 100) ]; - protected $rules [ - 'name' => ['required', 'string'] + protected $rules = [ + 'name' => ['sometimes', 'string', 'max:255'], + 'price' => ['sometimes', 'numeric', 'min:0'] ]; - public function name($value) + public function name(Payload $payload) + { + return $this->builder->where('name', 'like', $payload->asLike()); + } + + public function price(Payload $payload) { - return $this->builder->where('name', $value); + return $this->builder->where('price', '<=', $payload->asInt()); } } ``` + +### Example 2: User Search Filter + +```php +class UserFilter extends Filterable +{ + protected $sanitizers = [ + 'trim', // Global: trim all inputs + + 'email' => 'lowercase|null_if_empty', + 'username' => 'lowercase', + 'age' => 'integer', + 'bio' => 'strip_tags|escape_html', + 'role' => fn($value) => in_array($value, ['admin', 'user']) ? $value : 'user' + ]; + + public function email(Payload $payload) + { + return $this->builder->where('email', $payload); + } + + public function username(Payload $payload) + { + return $this->builder->where('username', 'like', $payload->asLike()); + } +} +``` + +--- + +## Tips & Best Practices + +::: tip Always Trim User Input +Add `'trim'` as a global sanitizer to prevent whitespace-related bugs: + +```php +protected $sanitizers = [ + 'trim', + // other sanitizers... +]; +``` + +::: + +::: tip Use Pipe Syntax for Readability +Instead of arrays, use pipe-separated strings for better readability: + +```php +// ✅ Good +'email' => 'trim|lowercase|null_if_empty' + +// ❌ Less readable +'email' => [TrimSanitizer::class, LowercaseSanitizer::class, NullIfEmptySanitizer::class] +``` + +::: + +::: warning Order Matters +Sanitizers execute in the order defined. Make sure the order is logical: + +```php +// ✅ Correct order +'slug' => 'trim|lowercase|slug' + +// ❌ Wrong order (slug should be last) +'slug' => 'slug|trim|lowercase' +``` + +::: diff --git a/src/Sanitization/Defaults/BooleanSanitizer.php b/src/Sanitization/Defaults/BooleanSanitizer.php new file mode 100644 index 00000000..5b621d61 --- /dev/null +++ b/src/Sanitization/Defaults/BooleanSanitizer.php @@ -0,0 +1,52 @@ + 'boolean']; + * // "yes" → true | "off" → false + */ +class BooleanSanitizer implements Sanitizable +{ + /** + * String values treated as true. + * @var array + */ + protected array $truthy = ['true', '1', 'yes', 'on']; + + /** + * String values treated as false. + * @var array + */ + protected array $falsy = ['false', '0', 'no', 'off', '']; + + public function sanitize($value): mixed + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $lower = mb_strtolower(trim($value)); + + if (in_array($lower, $this->truthy, true)) return true; + if (in_array($lower, $this->falsy, true)) return false; + } + + if (is_int($value)) { + return $value !== 0; + } + + return (bool) $value; + } +} diff --git a/src/Sanitization/Defaults/ClampSanitizer.php b/src/Sanitization/Defaults/ClampSanitizer.php new file mode 100644 index 00000000..48b9c836 --- /dev/null +++ b/src/Sanitization/Defaults/ClampSanitizer.php @@ -0,0 +1,67 @@ + ClampSanitizer::class]; + * // new ClampSanitizer(1, 100): 150 → 100 | 0 → 1 + */ +class ClampSanitizer implements Sanitizable +{ + /** + * Minimum allowed value (inclusive). Null = no lower bound. + * @var int|float|null + */ + protected int|float|null $min; + + /** + * Maximum allowed value (inclusive). Null = no upper bound. + * @var int|float|null + */ + protected int|float|null $max; + + public function __construct(int|float|null $min = null, int|float|null $max = null) + { + $this->min = $min; + $this->max = $max; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->clamp($v), $value); + } + + return $this->clamp($value); + } + + protected function clamp(mixed $value): mixed + { + if (! is_numeric($value)) { + return $value; + } + + // Preserve float type when either bound is a float + $value = (is_float($this->min) || is_float($this->max)) + ? (float) $value + : (int) $value; + + if ($this->min !== null) { + $value = max($this->min, $value); + } + + if ($this->max !== null) { + $value = min($this->max, $value); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/EscapeHtmlSanitizer.php b/src/Sanitization/Defaults/EscapeHtmlSanitizer.php new file mode 100644 index 00000000..574b6bcf --- /dev/null +++ b/src/Sanitization/Defaults/EscapeHtmlSanitizer.php @@ -0,0 +1,47 @@ + 'escape_html']; + * // "" → "<script>alert(1)</script>" + */ +class EscapeHtmlSanitizer implements Sanitizable +{ + /** + * Character encoding. + * @var string + */ + protected string $encoding; + + public function __construct(string $encoding = 'UTF-8') + { + $this->encoding = $encoding; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, $this->encoding); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) + ? htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, $this->encoding) + : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/FloatSanitizer.php b/src/Sanitization/Defaults/FloatSanitizer.php new file mode 100644 index 00000000..2d90d040 --- /dev/null +++ b/src/Sanitization/Defaults/FloatSanitizer.php @@ -0,0 +1,45 @@ + FloatSanitizer::class]; + * // "19.999abc" → 19.999 | new FloatSanitizer(2) → 20.0 + */ +class FloatSanitizer implements Sanitizable +{ + /** + * Number of decimal places to round to (null = no rounding). + * @var int|null + */ + protected ?int $decimals; + + public function __construct(?int $decimals = null) + { + $this->decimals = $decimals; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->cast($v), $value); + } + + return $this->cast($value); + } + + protected function cast(mixed $value): float + { + $float = (float) filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION); + + return $this->decimals !== null ? round($float, $this->decimals) : $float; + } +} diff --git a/src/Sanitization/Defaults/IntegerSanitizer.php b/src/Sanitization/Defaults/IntegerSanitizer.php new file mode 100644 index 00000000..4e17f03c --- /dev/null +++ b/src/Sanitization/Defaults/IntegerSanitizer.php @@ -0,0 +1,47 @@ + 'integer']; + * // "42abc" → 42 | "xyz" → 0 + */ +class IntegerSanitizer implements Sanitizable +{ + /** + * Return null instead of 0 for non-numeric values. + * @var bool + */ + protected bool $nullOnFail; + + public function __construct(bool $nullOnFail = false) + { + $this->nullOnFail = $nullOnFail; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->cast($v), $value); + } + + return $this->cast($value); + } + + protected function cast(mixed $value): ?int + { + if (is_numeric($value)) { + return (int) $value; + } + + return $this->nullOnFail ? null : (int) $value; + } +} diff --git a/src/Sanitization/Defaults/LowercaseSanitizer.php b/src/Sanitization/Defaults/LowercaseSanitizer.php new file mode 100644 index 00000000..a21171d9 --- /dev/null +++ b/src/Sanitization/Defaults/LowercaseSanitizer.php @@ -0,0 +1,33 @@ + 'lowercase']; + * // "Hello@Example.COM" → "hello@example.com" + */ +class LowercaseSanitizer implements Sanitizable +{ + public function sanitize($value): mixed + { + if (is_string($value)) { + return mb_strtolower($value); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? mb_strtolower($v) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/NullIfEmptySanitizer.php b/src/Sanitization/Defaults/NullIfEmptySanitizer.php new file mode 100644 index 00000000..c1acdf2a --- /dev/null +++ b/src/Sanitization/Defaults/NullIfEmptySanitizer.php @@ -0,0 +1,51 @@ + 'null_if_empty']; + * // "" → null | "0" → null | "hello" → "hello" + */ +class NullIfEmptySanitizer implements Sanitizable +{ + /** + * String representations that should be treated as empty. + * @var array + */ + protected array $emptyValues; + + public function __construct(array $emptyValues = ['', '0', 'null', 'undefined', 'none']) + { + $this->emptyValues = $emptyValues; + } + + public function sanitize($value): mixed + { + if (is_array($value)) { + return array_map(fn($v) => $this->nullify($v), $value); + } + + return $this->nullify($value); + } + + protected function nullify(mixed $value): mixed + { + if (is_null($value)) { + return null; + } + + if (is_string($value) && in_array(trim($value), $this->emptyValues, true)) { + return null; + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/SlugSanitizer.php b/src/Sanitization/Defaults/SlugSanitizer.php new file mode 100644 index 00000000..3b5d60aa --- /dev/null +++ b/src/Sanitization/Defaults/SlugSanitizer.php @@ -0,0 +1,52 @@ + 'slug']; + * // "Hello World!" → "hello-world" + */ +class SlugSanitizer implements Sanitizable +{ + /** + * Word separator character. + * @var string + */ + protected string $separator; + + /** + * Language for ASCII transliteration. + * @var string + */ + protected string $language; + + public function __construct(string $separator = '-', string $language = 'en') + { + $this->separator = $separator; + $this->language = $language; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return Str::slug($value, $this->separator, $this->language); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? Str::slug($v, $this->separator, $this->language) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/StripSpecialCharsSanitizer.php b/src/Sanitization/Defaults/StripSpecialCharsSanitizer.php new file mode 100644 index 00000000..c3eee214 --- /dev/null +++ b/src/Sanitization/Defaults/StripSpecialCharsSanitizer.php @@ -0,0 +1,63 @@ + 'strip_chars']; + * // "hello@#$world!" → "helloworld" + * + * // Allow underscores and dashes: + * protected $sanitizers = ['username' => new StripSpecialCharsSanitizer('_-')]; + * // "hello_world-2025!" → "hello_world-2025" + */ +class StripSpecialCharsSanitizer implements Sanitizable +{ + /** + * Extra characters to allow in addition to alphanumerics and spaces. + * @var string + */ + protected string $allowed; + + /** + * Replacement string for each stripped character. + * @var string + */ + protected string $replacement; + + public function __construct(string $allowed = '', string $replacement = '') + { + $this->allowed = $allowed; + $this->replacement = $replacement; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return $this->strip($value); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? $this->strip($v) : $v, + $value + ); + } + + return $value; + } + + protected function strip(string $value): string + { + $extra = preg_quote($this->allowed, '/'); + + return preg_replace("/[^a-zA-Z0-9\s{$extra}]/u", $this->replacement, $value); + } +} diff --git a/src/Sanitization/Defaults/StripTagsSanitizer.php b/src/Sanitization/Defaults/StripTagsSanitizer.php new file mode 100644 index 00000000..618839c1 --- /dev/null +++ b/src/Sanitization/Defaults/StripTagsSanitizer.php @@ -0,0 +1,44 @@ + 'strip_tags']; + * // "Hello " → "Hello " + */ +class StripTagsSanitizer implements Sanitizable +{ + /** + * Allowed HTML tags (e.g. ''). + * @var string|null + */ + protected ?string $allowedTags; + + public function __construct(?string $allowedTags = null) + { + $this->allowedTags = $allowedTags; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return strip_tags($value, $this->allowedTags); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? strip_tags($v, $this->allowedTags) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/TrimSanitizer.php b/src/Sanitization/Defaults/TrimSanitizer.php new file mode 100644 index 00000000..a77e3471 --- /dev/null +++ b/src/Sanitization/Defaults/TrimSanitizer.php @@ -0,0 +1,44 @@ + 'trim']; + * // " hello " → "hello" + */ +class TrimSanitizer implements Sanitizable +{ + /** + * Characters to trim (default: whitespace). + * @var string + */ + protected string $characters; + + public function __construct(string $characters = " \t\n\r\0\x0B") + { + $this->characters = $characters; + } + + public function sanitize($value): mixed + { + if (is_string($value)) { + return trim($value, $this->characters); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? trim($v, $this->characters) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/Defaults/UppercaseSanitizer.php b/src/Sanitization/Defaults/UppercaseSanitizer.php new file mode 100644 index 00000000..32f6d3a9 --- /dev/null +++ b/src/Sanitization/Defaults/UppercaseSanitizer.php @@ -0,0 +1,33 @@ + 'uppercase']; + * // "active" → "ACTIVE" + */ +class UppercaseSanitizer implements Sanitizable +{ + public function sanitize($value): mixed + { + if (is_string($value)) { + return mb_strtoupper($value); + } + + if (is_array($value)) { + return array_map( + fn($v) => is_string($v) ? mb_strtoupper($v) : $v, + $value + ); + } + + return $value; + } +} diff --git a/src/Sanitization/HandlerFactory.php b/src/Sanitization/HandlerFactory.php deleted file mode 100644 index 644eb89b..00000000 --- a/src/Sanitization/HandlerFactory.php +++ /dev/null @@ -1,41 +0,0 @@ -handle($value); - } - - /** - * Create SanitizerHandler instance based on sanitizer type. - * @param mixed $sanitizer - * @throws \RuntimeException - * @return SanitizeHandler - */ - protected static function makeHandler($sanitizer): SanitizeHandler - { - $handler = match (true) { - is_string($sanitizer) => new StringHandler($sanitizer), - is_callable($sanitizer) => new ClosureHandler($sanitizer), - is_array($sanitizer) => new ArrayHandler($sanitizer), - is_object($sanitizer) => new ObjectHandler($sanitizer), - default => throw new \RuntimeException("Handler is not processable"), - }; - - return $handler; - } -} diff --git a/src/Sanitization/Handlers/ArrayHandler.php b/src/Sanitization/Handlers/ArrayHandler.php index 68c2f49c..7de7fe1f 100644 --- a/src/Sanitization/Handlers/ArrayHandler.php +++ b/src/Sanitization/Handlers/ArrayHandler.php @@ -2,8 +2,8 @@ namespace Kettasoft\Filterable\Sanitization\Handlers; +use Kettasoft\Filterable\Sanitization\Sanitizer; use Kettasoft\Filterable\Sanitization\Contracts\SanitizeHandler; -use Kettasoft\Filterable\Sanitization\HandlerFactory; class ArrayHandler implements SanitizeHandler { @@ -26,7 +26,7 @@ public function __construct($sanitizers) public function handle(mixed $value): mixed { foreach ($this->sanitizers as $sanitizer) { - $value = HandlerFactory::handle($value, $sanitizer); + $value = Sanitizer::apply($value, $sanitizer); } return $value; diff --git a/src/Sanitization/Sanitizer.php b/src/Sanitization/Sanitizer.php index 26261409..5e7517d7 100644 --- a/src/Sanitization/Sanitizer.php +++ b/src/Sanitization/Sanitizer.php @@ -3,12 +3,49 @@ namespace Kettasoft\Filterable\Sanitization; use Illuminate\Support\Traits\ForwardsCalls; -use Kettasoft\Filterable\Sanitization\HandlerFactory; +use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\FloatSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\SlugSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\ClampSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\IntegerSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\BooleanSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\LowercaseSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\UppercaseSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\EscapeHtmlSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\StripTagsSanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\NullIfEmptySanitizer; +use Kettasoft\Filterable\Sanitization\Defaults\StripSpecialCharsSanitizer; +use Kettasoft\Filterable\Sanitization\Handlers\ArrayHandler; +use Kettasoft\Filterable\Sanitization\Handlers\ObjectHandler; +use Kettasoft\Filterable\Sanitization\Handlers\StringHandler; +use Kettasoft\Filterable\Sanitization\Handlers\ClosureHandler; +use Kettasoft\Filterable\Sanitization\Contracts\SanitizeHandler; class Sanitizer implements \Countable { use ForwardsCalls; + /** + * Built-in alias → FQCN map. + * Can be extended at runtime via {@see Sanitizer::extend()}. + * + * @var array + */ + protected static array $aliases = [ + 'trim' => TrimSanitizer::class, + 'lowercase' => LowercaseSanitizer::class, + 'uppercase' => UppercaseSanitizer::class, + 'integer' => IntegerSanitizer::class, + 'float' => FloatSanitizer::class, + 'boolean' => BooleanSanitizer::class, + 'slug' => SlugSanitizer::class, + 'strip_tags' => StripTagsSanitizer::class, + 'strip_chars' => StripSpecialCharsSanitizer::class, + 'escape_html' => EscapeHtmlSanitizer::class, + 'null_if_empty' => NullIfEmptySanitizer::class, + 'clamp' => ClampSanitizer::class, + ]; + /** * Registered sanitizers to operate upon. * @var array @@ -25,25 +62,108 @@ public function __construct(array $sanitizers) } /** - * Handle sanitizers. + * Register a custom alias that maps a short name to a Sanitizable class. + * + * @param string $alias Short name, e.g. 'my_trim' + * @param class-string $class FQCN implementing Sanitizable + * @return void + */ + public static function extend(string $alias, string $class): void + { + static::$aliases[$alias] = $class; + } + + /** + * Resolve an alias string to its registered FQCN, or return the input as-is + * if it is already a fully-qualified class name (or unregistered string). + * + * @param string $alias + * @return string FQCN + */ + public static function resolveAlias(string $alias): string + { + return static::$aliases[$alias] ?? $alias; + } + + /** + * Return a copy of the full alias → class map. + * + * @return array + */ + public static function getAliases(): array + { + return static::$aliases; + } + + /** + * Handle sanitizers for a given field value. + * + * Applies sanitizers in two phases: + * 1. Global sanitizers (numeric keys) - applied to all fields + * 2. Field-specific sanitizers (string keys) - applied to matching fields only + * * @param string $field - * @param mixed $value + * @param mixed $value + * @return mixed */ - public function handle(string $field, mixed $value) + public function handle(string $field, mixed $value): mixed { - if (empty($field) || !array_key_exists($field, $this->sanitizers)) { + if (empty($field)) { return $value; } + // Phase 1: Apply global sanitizers (numeric keys) foreach ($this->sanitizers as $key => $resolver) { - if ($key === $field) { - $value = HandlerFactory::handle($value, $resolver); + if (is_int($key)) { + $value = static::apply($value, $resolver); } } + // Phase 2: Apply field-specific sanitizers (string keys) + if (array_key_exists($field, $this->sanitizers)) { + $value = static::apply($value, $this->sanitizers[$field]); + } + return $value; } + /** + * Apply a single sanitizer resolver to a value. + * The resolver can be a string alias, a fully-qualified class name, a closure, + * an array of resolvers, or a Sanitizable object. + * + * @param mixed $value + * @param mixed $sanitizer + * @throws \RuntimeException + * @return mixed + */ + public static function apply(mixed $value, mixed $sanitizer): mixed + { + if (is_string($sanitizer) && str_contains($sanitizer, '|')) { + $sanitizer = explode('|', $sanitizer); + } + + return static::makeHandler($sanitizer)->handle($value); + } + + /** + * Build the appropriate SanitizeHandler for the given sanitizer definition. + * + * @param mixed $sanitizer + * @throws \RuntimeException + * @return SanitizeHandler + */ + protected static function makeHandler(mixed $sanitizer): SanitizeHandler + { + return match (true) { + is_string($sanitizer) => new StringHandler(static::resolveAlias($sanitizer)), + is_callable($sanitizer) => new ClosureHandler($sanitizer), + is_array($sanitizer) => new ArrayHandler($sanitizer), + is_object($sanitizer) => new ObjectHandler($sanitizer), + default => throw new \RuntimeException("Handler is not processable"), + }; + } + /** * Get the number of registered sanitizers. * @return int @@ -63,9 +183,10 @@ public function getSanitizers(): array } /** - * Set sanitizer classes + * Set sanitizer classes. + * * @param array $sanitizers - * @param bool $override Override current sanitizers when (true) + * @param bool $override Override current sanitizers when true * @return static */ public function setSanitizers(array $sanitizers, bool $override = true): static diff --git a/tests/Unit/Sanitization/SanitizeUnitTest.php b/tests/Unit/Sanitization/SanitizeUnitTest.php index 7c858be2..31c38e7d 100644 --- a/tests/Unit/Sanitization/SanitizeUnitTest.php +++ b/tests/Unit/Sanitization/SanitizeUnitTest.php @@ -2,7 +2,8 @@ namespace Kettasoft\Filterable\Tests\Unit\Sanitization; -use Kettasoft\Filterable\Sanitization\HandlerFactory; +use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Sanitizer; use Kettasoft\Filterable\Tests\TestCase; class SanitizeUnitTest extends TestCase @@ -16,7 +17,7 @@ public function it_can_sanitize_value_using_resolver_as_string() $value = ' value'; $resolver = TrimSanitizer::class; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = Sanitizer::apply($value, $resolver); $this->assertEquals(trim($value), $afterSanitize); } @@ -32,7 +33,7 @@ public function it_can_sanitize_value_using_resolver_as_function() return trim($value); }; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = Sanitizer::apply($value, $resolver); $this->assertEquals(trim($value), $afterSanitize); } @@ -46,7 +47,7 @@ public function it_can_sanitize_value_using_resolver_as_instance() $value = ' value'; $resolver = new TrimSanitizer; - $afterSanitize = HandlerFactory::handle($value, $resolver); + $afterSanitize = Sanitizer::apply($value, $resolver); $this->assertEquals(trim($value), $afterSanitize); } @@ -64,8 +65,91 @@ public function it_can_sanitize_value_using_resolver_as_array() new TrimSanitizer ]; - $afterSanitize = HandlerFactory::handle($value, $resolvers); + $afterSanitize = Sanitizer::apply($value, $resolvers); $this->assertEquals(strtoupper(trim($value)), $afterSanitize); } + + /** + * It applies global sanitizers to all fields. + * @test + */ + public function it_applies_global_sanitizers_to_all_fields() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, // Global sanitizer (numeric key) + 'email' => 'lowercase' + ]); + + $result = $sanitizer->handle('email', ' TEST@EXAMPLE.COM '); + + // Should trim first (global), then lowercase (field-specific) + $this->assertEquals('test@example.com', $result); + } + + /** + * It applies multiple global sanitizers in order. + * @test + */ + public function it_applies_multiple_global_sanitizers_in_order() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, // Global #1 + fn($v) => strtoupper($v), // Global #2 + 'name' => fn($v) => str_replace(' ', '_', $v) + ]); + + $result = $sanitizer->handle('name', ' john doe '); + + // Should: trim -> uppercase -> replace spaces + $this->assertEquals('JOHN_DOE', $result); + } + + /** + * It applies global sanitizers even when field has no specific sanitizer. + * @test + */ + public function it_applies_global_sanitizers_even_without_field_specific() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, // Global + 'email' => 'lowercase' + ]); + + $result = $sanitizer->handle('username', ' admin '); + + // Should apply trim even though 'username' has no specific sanitizer + $this->assertEquals('admin', $result); + } + + /** + * It works with only global sanitizers. + * @test + */ + public function it_works_with_only_global_sanitizers() + { + $sanitizer = new Sanitizer([ + TrimSanitizer::class, + fn($v) => strtolower($v) + ]); + + $result = $sanitizer->handle('any_field', ' HELLO '); + + $this->assertEquals('hello', $result); + } + + /** + * It returns value unchanged when no sanitizers match. + * @test + */ + public function it_returns_value_unchanged_when_no_sanitizers_match() + { + $sanitizer = new Sanitizer([ + 'email' => 'lowercase' + ]); + + $result = $sanitizer->handle('name', 'John Doe'); + + $this->assertEquals('John Doe', $result); + } } diff --git a/tests/Unit/Sanitization/SanitizerDefaultsTest.php b/tests/Unit/Sanitization/SanitizerDefaultsTest.php new file mode 100644 index 00000000..8e3290e2 --- /dev/null +++ b/tests/Unit/Sanitization/SanitizerDefaultsTest.php @@ -0,0 +1,444 @@ +assertSame('hello', $s->sanitize(' hello ')); + } + + public function test_trim_sanitizer_trims_each_element_in_array() + { + $s = new TrimSanitizer; + $this->assertSame(['hello', 'world'], $s->sanitize([' hello', 'world '])); + } + + public function test_trim_sanitizer_passes_non_string_unchanged() + { + $s = new TrimSanitizer; + $this->assertSame(42, $s->sanitize(42)); + $this->assertNull($s->sanitize(null)); + } + + public function test_trim_alias_resolves_via_handler_factory() + { + $this->assertSame('hello', Sanitizer::apply(' hello ', 'trim')); + } + + // ───────────────────────────────────────────── + // LowercaseSanitizer + // ───────────────────────────────────────────── + + public function test_lowercase_sanitizer_converts_string_to_lowercase() + { + $s = new LowercaseSanitizer; + $this->assertSame('hello world', $s->sanitize('Hello World')); + } + + public function test_lowercase_sanitizer_handles_multibyte() + { + $s = new LowercaseSanitizer; + $this->assertSame('héllo', $s->sanitize('HÉLLO')); + } + + public function test_lowercase_sanitizer_processes_array() + { + $s = new LowercaseSanitizer; + $this->assertSame(['foo', 'bar'], $s->sanitize(['FOO', 'BAR'])); + } + + public function test_lowercase_alias_resolves_via_handler_factory() + { + $this->assertSame('hello', Sanitizer::apply('HELLO', 'lowercase')); + } + + // ───────────────────────────────────────────── + // UppercaseSanitizer + // ───────────────────────────────────────────── + + public function test_uppercase_sanitizer_converts_string_to_uppercase() + { + $s = new UppercaseSanitizer; + $this->assertSame('HELLO WORLD', $s->sanitize('hello world')); + } + + public function test_uppercase_sanitizer_processes_array() + { + $s = new UppercaseSanitizer; + $this->assertSame(['FOO', 'BAR'], $s->sanitize(['foo', 'bar'])); + } + + public function test_uppercase_alias_resolves_via_handler_factory() + { + $this->assertSame('HELLO', Sanitizer::apply('hello', 'uppercase')); + } + + // ───────────────────────────────────────────── + // StripTagsSanitizer + // ───────────────────────────────────────────── + + public function test_strip_tags_sanitizer_removes_html_tags() + { + $s = new StripTagsSanitizer; + $this->assertSame('Hello ', $s->sanitize('Hello ')); + } + + public function test_strip_tags_sanitizer_removes_script_tags() + { + $s = new StripTagsSanitizer; + // strip_tags removes the safe')); + } + + public function test_strip_tags_sanitizer_allows_specified_tags() + { + $s = new StripTagsSanitizer(''); + // ')); + } + + public function test_strip_tags_sanitizer_processes_array() + { + $s = new StripTagsSanitizer; + $this->assertSame(['Hello', 'World'], $s->sanitize(['Hello', 'World'])); + } + + public function test_strip_tags_alias_resolves_via_handler_factory() + { + $this->assertSame('Hello', Sanitizer::apply('Hello', 'strip_tags')); + } + + // ───────────────────────────────────────────── + // EscapeHtmlSanitizer + // ───────────────────────────────────────────── + + public function test_escape_html_sanitizer_escapes_special_chars() + { + $s = new EscapeHtmlSanitizer; + $this->assertSame('<b>Hi</b>', $s->sanitize('Hi')); + } + + public function test_escape_html_sanitizer_escapes_quotes() + { + $s = new EscapeHtmlSanitizer; + $this->assertSame('say "hi"', $s->sanitize('say "hi"')); + } + + public function test_escape_html_sanitizer_processes_array() + { + $s = new EscapeHtmlSanitizer; + $result = $s->sanitize(['a', 'b']); + $this->assertSame(['<b>a</b>', '<i>b</i>'], $result); + } + + public function test_escape_html_alias_resolves_via_handler_factory() + { + $this->assertSame('<b>', Sanitizer::apply('', 'escape_html')); + } + + // ───────────────────────────────────────────── + // IntegerSanitizer + // ───────────────────────────────────────────── + + public function test_integer_sanitizer_casts_numeric_string_to_int() + { + $s = new IntegerSanitizer; + $this->assertSame(42, $s->sanitize('42')); + } + + public function test_integer_sanitizer_strips_non_numeric_suffix() + { + $s = new IntegerSanitizer; + $this->assertSame(42, $s->sanitize('42abc')); + } + + public function test_integer_sanitizer_returns_null_on_fail_when_configured() + { + $s = new IntegerSanitizer(nullOnFail: true); + $this->assertNull($s->sanitize('abc')); + } + + public function test_integer_sanitizer_processes_array() + { + $s = new IntegerSanitizer; + $this->assertSame([1, 2, 3], $s->sanitize(['1', '2', '3'])); + } + + public function test_integer_alias_resolves_via_handler_factory() + { + $this->assertSame(7, Sanitizer::apply('7', 'integer')); + } + + // ───────────────────────────────────────────── + // FloatSanitizer + // ───────────────────────────────────────────── + + public function test_float_sanitizer_casts_value_to_float() + { + $s = new FloatSanitizer; + $this->assertSame(3.14, $s->sanitize('3.14')); + } + + public function test_float_sanitizer_rounds_to_given_decimals() + { + $s = new FloatSanitizer(decimals: 2); + $this->assertSame(3.14, $s->sanitize('3.1415')); + } + + public function test_float_sanitizer_processes_array() + { + $s = new FloatSanitizer(decimals: 1); + $this->assertSame([1.1, 2.3], $s->sanitize(['1.14', '2.25'])); + } + + public function test_float_alias_resolves_via_handler_factory() + { + $this->assertSame(1.5, Sanitizer::apply('1.5', 'float')); + } + + // ───────────────────────────────────────────── + // BooleanSanitizer + // ───────────────────────────────────────────── + + public function test_boolean_sanitizer_converts_truthy_strings() + { + $s = new BooleanSanitizer; + foreach (['true', '1', 'yes', 'on', 'TRUE', 'YES'] as $v) { + $this->assertTrue($s->sanitize($v), "Expected true for: $v"); + } + } + + public function test_boolean_sanitizer_converts_falsy_strings() + { + $s = new BooleanSanitizer; + foreach (['false', '0', 'no', 'off', '', 'FALSE'] as $v) { + $this->assertFalse($s->sanitize($v), "Expected false for: $v"); + } + } + + public function test_boolean_sanitizer_passes_native_booleans() + { + $s = new BooleanSanitizer; + $this->assertTrue($s->sanitize(true)); + $this->assertFalse($s->sanitize(false)); + } + + public function test_boolean_sanitizer_handles_integer() + { + $s = new BooleanSanitizer; + $this->assertTrue($s->sanitize(1)); + $this->assertFalse($s->sanitize(0)); + } + + public function test_boolean_alias_resolves_via_handler_factory() + { + $this->assertTrue(Sanitizer::apply('yes', 'boolean')); + $this->assertFalse(Sanitizer::apply('no', 'boolean')); + } + + // ───────────────────────────────────────────── + // SlugSanitizer + // ───────────────────────────────────────────── + + public function test_slug_sanitizer_converts_to_slug() + { + $s = new SlugSanitizer; + $this->assertSame('hello-world', $s->sanitize('Hello World!')); + } + + public function test_slug_sanitizer_uses_custom_separator() + { + $s = new SlugSanitizer(separator: '_'); + $this->assertSame('hello_world', $s->sanitize('Hello World')); + } + + public function test_slug_sanitizer_processes_array() + { + $s = new SlugSanitizer; + $this->assertSame(['foo-bar', 'baz'], $s->sanitize(['Foo Bar', 'Baz'])); + } + + public function test_slug_alias_resolves_via_handler_factory() + { + $this->assertSame('hello-world', Sanitizer::apply('Hello World', 'slug')); + } + + // ───────────────────────────────────────────── + // NullIfEmptySanitizer + // ───────────────────────────────────────────── + + public function test_null_if_empty_sanitizer_returns_null_for_empty_string() + { + $s = new NullIfEmptySanitizer; + $this->assertNull($s->sanitize('')); + } + + public function test_null_if_empty_sanitizer_returns_null_for_default_empty_values() + { + $s = new NullIfEmptySanitizer; + foreach (['0', 'null', 'undefined', 'none'] as $v) { + $this->assertNull($s->sanitize($v), "Expected null for: $v"); + } + } + + public function test_null_if_empty_sanitizer_preserves_non_empty_string() + { + $s = new NullIfEmptySanitizer; + $this->assertSame('hello', $s->sanitize('hello')); + } + + public function test_null_if_empty_sanitizer_returns_null_for_null_input() + { + $s = new NullIfEmptySanitizer; + $this->assertNull($s->sanitize(null)); + } + + public function test_null_if_empty_sanitizer_processes_array() + { + $s = new NullIfEmptySanitizer; + $this->assertSame([null, 'hello', null], $s->sanitize(['', 'hello', 'null'])); + } + + public function test_null_if_empty_alias_resolves_via_handler_factory() + { + $this->assertNull(Sanitizer::apply('', 'null_if_empty')); + } + + // ───────────────────────────────────────────── + // ClampSanitizer + // ───────────────────────────────────────────── + + public function test_clamp_sanitizer_enforces_maximum() + { + $s = new ClampSanitizer(max: 100); + $this->assertSame(100, $s->sanitize(150)); + } + + public function test_clamp_sanitizer_enforces_minimum() + { + $s = new ClampSanitizer(min: 1); + $this->assertSame(1, $s->sanitize(0)); + } + + public function test_clamp_sanitizer_does_not_change_value_within_range() + { + $s = new ClampSanitizer(min: 1, max: 100); + $this->assertSame(50, $s->sanitize(50)); + } + + public function test_clamp_sanitizer_works_with_float_bounds() + { + $s = new ClampSanitizer(min: 1.0, max: 5.0); + $this->assertSame(5.0, $s->sanitize(10.0)); + $this->assertSame(1.0, $s->sanitize(0.5)); + } + + public function test_clamp_sanitizer_passes_non_numeric_unchanged() + { + $s = new ClampSanitizer(min: 1, max: 100); + $this->assertSame('abc', $s->sanitize('abc')); + } + + public function test_clamp_sanitizer_processes_array() + { + $s = new ClampSanitizer(min: 1, max: 10); + $this->assertSame([1, 10, 5], $s->sanitize([0, 20, 5])); + } + + public function test_clamp_alias_resolves_via_handler_factory() + { + // alias uses ClampSanitizer with no bounds — value passes through + $this->assertSame(200, Sanitizer::apply(200, 'clamp')); + } + + // ───────────────────────────────────────────── + // StripSpecialCharsSanitizer + // ───────────────────────────────────────────── + + public function test_strip_special_chars_sanitizer_removes_special_characters() + { + $s = new StripSpecialCharsSanitizer; + $this->assertSame('hello world', $s->sanitize('hello@#$ world!')); + } + + public function test_strip_special_chars_sanitizer_allows_extra_chars() + { + $s = new StripSpecialCharsSanitizer(allowed: '_-'); + $this->assertSame('hello_world-2025', $s->sanitize('hello_world-2025!@#')); + } + + public function test_strip_special_chars_sanitizer_uses_replacement() + { + $s = new StripSpecialCharsSanitizer(replacement: '*'); + $this->assertSame('hello*', $s->sanitize('hello!')); + } + + public function test_strip_special_chars_sanitizer_processes_array() + { + $s = new StripSpecialCharsSanitizer; + $this->assertSame(['hello', 'world'], $s->sanitize(['hello!', 'world@'])); + } + + public function test_strip_chars_alias_resolves_via_handler_factory() + { + $this->assertSame('hello', Sanitizer::apply('hello!', 'strip_chars')); + } + + // ───────────────────────────────────────────── + // Sanitizer::aliases() & extend() + // ───────────────────────────────────────────── + + public function test_handler_factory_returns_all_built_in_aliases() + { + $aliases = Sanitizer::getAliases(); + + $expected = [ + 'trim', + 'lowercase', + 'uppercase', + 'strip_tags', + 'escape_html', + 'integer', + 'float', + 'boolean', + 'slug', + 'null_if_empty', + 'clamp', + 'strip_chars', + ]; + + foreach ($expected as $alias) { + $this->assertArrayHasKey($alias, $aliases, "Missing alias: $alias"); + } + } + + public function test_handler_factory_extend_registers_custom_alias() + { + Sanitizer::extend('my_trim', TrimSanitizer::class); + + $this->assertArrayHasKey('my_trim', Sanitizer::getAliases()); + $this->assertSame('hello', Sanitizer::apply(' hello ', 'my_trim')); + } +} diff --git a/tests/Unit/Sanitization/TrimSanitizer.php b/tests/Unit/Sanitization/TrimSanitizer.php deleted file mode 100644 index 05418c4d..00000000 --- a/tests/Unit/Sanitization/TrimSanitizer.php +++ /dev/null @@ -1,13 +0,0 @@ - Date: Mon, 7 Sep 2026 07:03:26 +0300 Subject: [PATCH 2/2] fix: harden sanitizer resolution and edge cases --- docs/sanitization.md | 7 +++ .../Defaults/EscapeHtmlSanitizer.php | 5 +- .../Defaults/NullIfEmptySanitizer.php | 4 +- .../Defaults/StripTagsSanitizer.php | 2 +- src/Sanitization/Handlers/ClosureHandler.php | 6 +- src/Sanitization/Sanitizer.php | 10 ++++ tests/Unit/Engines/InvokableEngineTest.php | 12 ++-- tests/Unit/Sanitization/SanitizeUnitTest.php | 57 +++++++++++++++++++ .../Sanitization/SanitizerDefaultsTest.php | 18 +++++- 9 files changed, 110 insertions(+), 11 deletions(-) diff --git a/docs/sanitization.md b/docs/sanitization.md index 018d2c68..cf5b85fd 100644 --- a/docs/sanitization.md +++ b/docs/sanitization.md @@ -70,6 +70,13 @@ The package includes 12 ready-to-use sanitizers accessible via **short aliases** --- +::: warning Output Escaping +`strip_tags` removes markup but preserves text inside tags, including script +contents. `escape_html` is an output-formatting helper for HTML contexts; it is +not a substitute for validation, authorization, or context-aware escaping at +the final output boundary. +::: + ## Usage Patterns ### 1. Using Aliases (Recommended) diff --git a/src/Sanitization/Defaults/EscapeHtmlSanitizer.php b/src/Sanitization/Defaults/EscapeHtmlSanitizer.php index 574b6bcf..cbc5a39d 100644 --- a/src/Sanitization/Defaults/EscapeHtmlSanitizer.php +++ b/src/Sanitization/Defaults/EscapeHtmlSanitizer.php @@ -5,8 +5,9 @@ use Kettasoft\Filterable\Sanitization\Contracts\Sanitizable; /** - * Converts HTML special characters to their entity equivalents, - * preventing XSS while preserving the string for safe output. + * Converts HTML special characters to their entity equivalents for use in + * HTML text contexts. Applications must still escape values at their final + * output boundary according to that output context. * * Alias: 'escape_html' * diff --git a/src/Sanitization/Defaults/NullIfEmptySanitizer.php b/src/Sanitization/Defaults/NullIfEmptySanitizer.php index c1acdf2a..8216811c 100644 --- a/src/Sanitization/Defaults/NullIfEmptySanitizer.php +++ b/src/Sanitization/Defaults/NullIfEmptySanitizer.php @@ -12,7 +12,7 @@ * * @example * protected $sanitizers = ['search' => 'null_if_empty']; - * // "" → null | "0" → null | "hello" → "hello" + * // "" → null | "0" → "0" | "hello" → "hello" */ class NullIfEmptySanitizer implements Sanitizable { @@ -22,7 +22,7 @@ class NullIfEmptySanitizer implements Sanitizable */ protected array $emptyValues; - public function __construct(array $emptyValues = ['', '0', 'null', 'undefined', 'none']) + public function __construct(array $emptyValues = ['', 'null', 'undefined', 'none']) { $this->emptyValues = $emptyValues; } diff --git a/src/Sanitization/Defaults/StripTagsSanitizer.php b/src/Sanitization/Defaults/StripTagsSanitizer.php index 618839c1..50ec47f5 100644 --- a/src/Sanitization/Defaults/StripTagsSanitizer.php +++ b/src/Sanitization/Defaults/StripTagsSanitizer.php @@ -11,7 +11,7 @@ * * @example * protected $sanitizers = ['bio' => 'strip_tags']; - * // "Hello " → "Hello " + * // "Hello " → "Hello alert(1)" */ class StripTagsSanitizer implements Sanitizable { diff --git a/src/Sanitization/Handlers/ClosureHandler.php b/src/Sanitization/Handlers/ClosureHandler.php index 5d8ede54..7c96a7db 100644 --- a/src/Sanitization/Handlers/ClosureHandler.php +++ b/src/Sanitization/Handlers/ClosureHandler.php @@ -14,7 +14,11 @@ class ClosureHandler implements SanitizeHandler */ public function __construct($sanitizer) { - $this->sanitizer = $sanitizer; + if (!is_callable($sanitizer)) { + throw new \InvalidArgumentException('The sanitizer must be callable.'); + } + + $this->sanitizer = \Closure::fromCallable($sanitizer); } /** diff --git a/src/Sanitization/Sanitizer.php b/src/Sanitization/Sanitizer.php index 5e7517d7..50f9cf8f 100644 --- a/src/Sanitization/Sanitizer.php +++ b/src/Sanitization/Sanitizer.php @@ -19,6 +19,7 @@ use Kettasoft\Filterable\Sanitization\Handlers\ObjectHandler; use Kettasoft\Filterable\Sanitization\Handlers\StringHandler; use Kettasoft\Filterable\Sanitization\Handlers\ClosureHandler; +use Kettasoft\Filterable\Sanitization\Contracts\Sanitizable; use Kettasoft\Filterable\Sanitization\Contracts\SanitizeHandler; class Sanitizer implements \Countable @@ -70,6 +71,14 @@ public function __construct(array $sanitizers) */ public static function extend(string $alias, string $class): void { + if (!is_a($class, Sanitizable::class, true)) { + throw new \InvalidArgumentException(sprintf( + 'Sanitizer class [%s] must implement [%s].', + $class, + Sanitizable::class + )); + } + static::$aliases[$alias] = $class; } @@ -156,6 +165,7 @@ public static function apply(mixed $value, mixed $sanitizer): mixed protected static function makeHandler(mixed $sanitizer): SanitizeHandler { return match (true) { + $sanitizer instanceof Sanitizable => new ObjectHandler($sanitizer), is_string($sanitizer) => new StringHandler(static::resolveAlias($sanitizer)), is_callable($sanitizer) => new ClosureHandler($sanitizer), is_array($sanitizer) => new ArrayHandler($sanitizer), diff --git a/tests/Unit/Engines/InvokableEngineTest.php b/tests/Unit/Engines/InvokableEngineTest.php index 25b21824..b563291e 100644 --- a/tests/Unit/Engines/InvokableEngineTest.php +++ b/tests/Unit/Engines/InvokableEngineTest.php @@ -889,12 +889,14 @@ public function it_can_access_raw_payload_value() 'title' => 'trim' ]; + public $sanitizedValue; + + public $rawValue; + public function title(Payload $payload) { - // Value is sanitized - $this->assertEquals('Test', $payload->value); - // Raw value is not sanitized - $this->assertEquals(' Test ', $payload->raw()); + $this->sanitizedValue = $payload->value; + $this->rawValue = $payload->raw(); return $this->builder->where('title', $payload->value); } @@ -902,6 +904,8 @@ public function title(Payload $payload) $posts = Post::filter($filter)->get(); + $this->assertSame('Test', $filter->sanitizedValue); + $this->assertSame(' Test ', $filter->rawValue); $this->assertCount(1, $posts); } diff --git a/tests/Unit/Sanitization/SanitizeUnitTest.php b/tests/Unit/Sanitization/SanitizeUnitTest.php index 31c38e7d..65ba6e3c 100644 --- a/tests/Unit/Sanitization/SanitizeUnitTest.php +++ b/tests/Unit/Sanitization/SanitizeUnitTest.php @@ -3,6 +3,7 @@ namespace Kettasoft\Filterable\Tests\Unit\Sanitization; use Kettasoft\Filterable\Sanitization\Defaults\TrimSanitizer; +use Kettasoft\Filterable\Sanitization\Contracts\Sanitizable; use Kettasoft\Filterable\Sanitization\Sanitizer; use Kettasoft\Filterable\Tests\TestCase; @@ -70,6 +71,62 @@ public function it_can_sanitize_value_using_resolver_as_array() $this->assertEquals(strtoupper(trim($value)), $afterSanitize); } + /** + * It can sanitize a value using an invokable object. + * @test + */ + public function it_can_sanitize_value_using_an_invokable_object() + { + $resolver = new class { + public function __invoke($value) + { + return strtoupper(trim($value)); + } + }; + + $this->assertSame('VALUE', Sanitizer::apply(' value ', $resolver)); + } + + /** + * It can sanitize a value using a callable array. + * @test + */ + public function it_can_sanitize_value_using_a_callable_array() + { + $resolver = new class { + public function normalize($value) + { + return strtolower(trim($value)); + } + }; + + $this->assertSame( + 'value', + Sanitizer::apply(' VALUE ', [$resolver, 'normalize']) + ); + } + + /** + * Sanitizable objects take precedence over their __invoke method. + * @test + */ + public function it_uses_the_sanitizable_contract_for_invokable_sanitizers() + { + $resolver = new class implements Sanitizable { + public function sanitize($value): mixed + { + return 'sanitized:' . trim($value); + } + + public function __invoke($value) + { + return 'invoked:' . trim($value); + } + }; + + $this->assertSame('sanitized:value', Sanitizer::apply(' value ', $resolver)); + } + /** * It applies global sanitizers to all fields. * @test diff --git a/tests/Unit/Sanitization/SanitizerDefaultsTest.php b/tests/Unit/Sanitization/SanitizerDefaultsTest.php index 8e3290e2..ad1ca3bd 100644 --- a/tests/Unit/Sanitization/SanitizerDefaultsTest.php +++ b/tests/Unit/Sanitization/SanitizerDefaultsTest.php @@ -298,11 +298,19 @@ public function test_null_if_empty_sanitizer_returns_null_for_empty_string() public function test_null_if_empty_sanitizer_returns_null_for_default_empty_values() { $s = new NullIfEmptySanitizer; - foreach (['0', 'null', 'undefined', 'none'] as $v) { + foreach (['null', 'undefined', 'none'] as $v) { $this->assertNull($s->sanitize($v), "Expected null for: $v"); } } + public function test_null_if_empty_sanitizer_preserves_zero() + { + $s = new NullIfEmptySanitizer; + + $this->assertSame('0', $s->sanitize('0')); + $this->assertSame(0, $s->sanitize(0)); + } + public function test_null_if_empty_sanitizer_preserves_non_empty_string() { $s = new NullIfEmptySanitizer; @@ -441,4 +449,12 @@ public function test_handler_factory_extend_registers_custom_alias() $this->assertArrayHasKey('my_trim', Sanitizer::getAliases()); $this->assertSame('hello', Sanitizer::apply(' hello ', 'my_trim')); } + + public function test_extend_rejects_a_class_that_is_not_sanitizable() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + Sanitizer::extend('invalid', \stdClass::class); + } }