Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/migrating-from-v2-to-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ Version 3.0 introduces several new validators:
| `KeyExists` | Checks if an array key exists |
| `KeyOptional` | Validates an array key only if it exists |
| `Lazy` | Creates validators dynamically based on input |
| `Masked` | Masks sensitive input values in error messages |
| `Named` | Customizes the subject name in error messages |
| `PropertyExists` | Checks if an object property exists |
| `PropertyOptional` | Validates an object property only if it exists |
Expand Down Expand Up @@ -701,6 +702,18 @@ v::lazy(
)->assert(['password' => 'secret', 'confirmation' => 'secret']); // passes
```

#### Masked

Decorates a validator to mask sensitive input values in error messages while still validating the original unmasked data. This allows applications to protect sensitive information like passwords, credit cards, or emails without implementing a custom layer between Validation and end users:

```php
v::masked('1-@', v::email(),v::email())->assert('invalid@example.com');
// → "*******@example.com" must be a valid email address

v::masked('6-12', v::creditCard(), 'X')->assert('4111111111111211');
// → "41111XXXXXXX1211" must be a valid credit card number
```

#### Named

Customizes the subject name in error messages:
Expand Down
8 changes: 6 additions & 2 deletions docs/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ In this page you will find a list of validators by their category.

**Date and Time**: [Date][] - [DateTime][] - [DateTimeDiff][] - [LeapDate][] - [LeapYear][] - [Time][]

**Display**: [Masked][] - [Named][] - [Templated][]

**File system**: [Directory][] - [Executable][] - [Exists][] - [Extension][] - [File][] - [Image][] - [Mimetype][] - [Readable][] - [Size][] - [SymbolicLink][] - [Writable][]

**ISO codes**: [CountryCode][] - [CurrencyCode][] - [LanguageCode][] - [SubdivisionCode][]
Expand All @@ -37,7 +39,7 @@ In this page you will find a list of validators by their category.

**Math**: [Factor][] - [Finite][] - [Infinite][] - [Multiple][] - [Negative][] - [Positive][]

**Miscellaneous**: [Blank][] - [Falsy][] - [Named][] - [Templated][] - [Undef][]
**Miscellaneous**: [Blank][] - [Falsy][] - [Masked][] - [Named][] - [Templated][] - [Undef][]

**Nesting**: [AllOf][] - [AnyOf][] - [Call][] - [Circuit][] - [Each][] - [Key][] - [KeySet][] - [Lazy][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [UndefOr][] - [When][]

Expand All @@ -47,7 +49,7 @@ In this page you will find a list of validators by their category.

**Strings**: [Alnum][] - [Alpha][] - [Base64][] - [Charset][] - [Consonant][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Control][] - [Digit][] - [Emoji][] - [EndsWith][] - [Graph][] - [HexRgbColor][] - [In][] - [Json][] - [Lowercase][] - [Phone][] - [PostalCode][] - [Printable][] - [Punct][] - [Regex][] - [Slug][] - [Sorted][] - [Space][] - [Spaced][] - [StartsWith][] - [StringType][] - [StringVal][] - [Uppercase][] - [Uuid][] - [Version][] - [Vowel][] - [Xdigit][]

**Structures**: [Attributes][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Named][] - [Property][] - [PropertyExists][] - [PropertyOptional][] - [Templated][]
**Structures**: [Attributes][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Property][] - [PropertyExists][] - [PropertyOptional][]

**Transformations**: [All][] - [Call][] - [Each][] - [Length][] - [Max][] - [Min][] - [Size][]

Expand Down Expand Up @@ -147,6 +149,7 @@ In this page you will find a list of validators by their category.
- [Lowercase][] - `v::stringType()->lowercase()->assert('xkcd');`
- [Luhn][] - `v::luhn()->assert('2222400041240011');`
- [MacAddress][] - `v::macAddress()->assert('00:11:22:33:44:55');`
- [Masked][] - `v::masked('1-@', v::email())->assert('foo@example.com');`
- [Max][] - `v::max(v::equals(30))->assert([10, 20, 30]);`
- [Mimetype][] - `v::mimetype('image/png')->assert('/path/to/image.png');`
- [Min][] - `v::min(v::equals(10))->assert([10, 20, 30]);`
Expand Down Expand Up @@ -302,6 +305,7 @@ In this page you will find a list of validators by their category.
[Lowercase]: validators/Lowercase.md "Validates whether the characters in the input are lowercase."
[Luhn]: validators/Luhn.md "Validate whether a given input is a Luhn number."
[MacAddress]: validators/MacAddress.md "Validates whether the input is a valid MAC address."
[Masked]: validators/Masked.md "Decorates a validator to mask input values in error messages while still validating the original unmasked input."
[Max]: validators/Max.md "Validates the maximum value of the input against a given validator."
[Mimetype]: validators/Mimetype.md "Validates if the input is a file and if its MIME type matches the expected one."
[Min]: validators/Min.md "Validates the minimum value of the input against a given validator."
Expand Down
49 changes: 49 additions & 0 deletions docs/validators/Masked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: (c) Respect Project Contributors
SPDX-License-Identifier: MIT
-->

# Masked

- `Masked(string $range, Validator $validator)`
- `Masked(string $range, Validator $validator, string $replacement)`

Decorates a validator to mask input values in error messages while still validating the original unmasked input.

```php
v::masked('1-@', v::email())->assert('foo@example.com');
// Validation passes successfully

v::masked('1-@', v::email())->assert('invalid username@domain.com');
// → "****************@domain.com" must be a valid email address

v::masked('1-', v::lengthGreaterThan(10))->assert('password');
// → The length of "********" must be greater than 10

v::masked('6-12', v::creditCard(), 'X')->assert('4111111111111211');
// → "41111XXXXXXX1211" must be a valid credit card number
```

This validator is useful for security-sensitive applications where error messages should not expose sensitive data like credit card numbers, passwords, or email addresses.

It uses [respect/string-formatter](https://github.com/Respect/StringFormatter) as the underlying masking engine. See the section the documentation of [MaskFormatter](https://github.com/Respect/StringFormatter/blob/main/docs/MaskFormatter.md) for more information.

## Categorization

- Display
- Miscellaneous

## Behavior

The validator first ensures the input is a valid string using `StringVal`. If the input passes string validation, it validates the original unmasked input using the inner validator. If validation fails, it applies masking to the input value shown in error messages.

## Changelog

| Version | Description |
| ------: | :---------- |
| 3.0.0 | Created |

## See Also

- [Named](Named.md)
- [Templated](Templated.md)
3 changes: 2 additions & 1 deletion docs/validators/Named.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ This validator does not have any templates, as it will use the template of the g
## Categorization

- Core
- Structures
- Display
- Miscellaneous

## Changelog
Expand All @@ -51,5 +51,6 @@ This validator does not have any templates, as it will use the template of the g
## See Also

- [Attributes](Attributes.md)
- [Masked](Masked.md)
- [Not](Not.md)
- [Templated](Templated.md)
3 changes: 2 additions & 1 deletion docs/validators/Templated.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This validator does not have any templates, as you must define the templates you
## Categorization

- Core
- Structures
- Display
- Miscellaneous

## Changelog
Expand All @@ -52,5 +52,6 @@ This validator does not have any templates, as you must define the templates you
## See Also

- [Attributes](Attributes.md)
- [Masked](Masked.md)
- [Named](Named.md)
- [Not](Not.md)
2 changes: 2 additions & 0 deletions src/Mixins/AllBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ public static function allLuhn(): Chain;

public static function allMacAddress(): Chain;

public static function allMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public static function allMax(Validator $validator): Chain;

public static function allMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/AllChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ public function allLuhn(): Chain;

public function allMacAddress(): Chain;

public function allMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public function allMax(Validator $validator): Chain;

public function allMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ public static function luhn(): Chain;

public static function macAddress(): Chain;

public static function masked(string $range, Validator $validator, string $replacement = '*'): Chain;

public static function max(Validator $validator): Chain;

public static function mimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/Chain.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ public function luhn(): Chain;

public function macAddress(): Chain;

public function masked(string $range, Validator $validator, string $replacement = '*'): Chain;

public function max(Validator $validator): Chain;

public function mimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/KeyBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ public static function keyLuhn(int|string $key): Chain;

public static function keyMacAddress(int|string $key): Chain;

public static function keyMasked(int|string $key, string $range, Validator $validator, string $replacement = '*'): Chain;

public static function keyMax(int|string $key, Validator $validator): Chain;

public static function keyMimetype(int|string $key, string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/KeyChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ public function keyLuhn(int|string $key): Chain;

public function keyMacAddress(int|string $key): Chain;

public function keyMasked(int|string $key, string $range, Validator $validator, string $replacement = '*'): Chain;

public function keyMax(int|string $key, Validator $validator): Chain;

public function keyMimetype(int|string $key, string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NotBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ public static function notLuhn(): Chain;

public static function notMacAddress(): Chain;

public static function notMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public static function notMax(Validator $validator): Chain;

public static function notMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NotChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ public function notLuhn(): Chain;

public function notMacAddress(): Chain;

public function notMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public function notMax(Validator $validator): Chain;

public function notMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NullOrBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ public static function nullOrLuhn(): Chain;

public static function nullOrMacAddress(): Chain;

public static function nullOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public static function nullOrMax(Validator $validator): Chain;

public static function nullOrMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NullOrChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ public function nullOrLuhn(): Chain;

public function nullOrMacAddress(): Chain;

public function nullOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public function nullOrMax(Validator $validator): Chain;

public function nullOrMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/PropertyBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ public static function propertyLuhn(string $propertyName): Chain;

public static function propertyMacAddress(string $propertyName): Chain;

public static function propertyMasked(string $propertyName, string $range, Validator $validator, string $replacement = '*'): Chain;

public static function propertyMax(string $propertyName, Validator $validator): Chain;

public static function propertyMimetype(string $propertyName, string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/PropertyChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ public function propertyLuhn(string $propertyName): Chain;

public function propertyMacAddress(string $propertyName): Chain;

public function propertyMasked(string $propertyName, string $range, Validator $validator, string $replacement = '*'): Chain;

public function propertyMax(string $propertyName, Validator $validator): Chain;

public function propertyMimetype(string $propertyName, string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/UndefOrBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ public static function undefOrLuhn(): Chain;

public static function undefOrMacAddress(): Chain;

public static function undefOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public static function undefOrMax(Validator $validator): Chain;

public static function undefOrMimetype(string $mimetype): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/UndefOrChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ public function undefOrLuhn(): Chain;

public function undefOrMacAddress(): Chain;

public function undefOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain;

public function undefOrMax(Validator $validator): Chain;

public function undefOrMimetype(string $mimetype): Chain;
Expand Down
34 changes: 31 additions & 3 deletions src/Result.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public function asAdjacentOf(Result $result, string $prefix): Result
if ($this->allowsAdjacent()) {
return clone ($result, [
'id' => $this->id->withPrefix($prefix),
'adjacent' => clone($this, ['input' => $result->input]),
'adjacent' => $this->withInput($result->input),
]);
}

Expand Down Expand Up @@ -145,7 +145,10 @@ public function withoutName(): self
return clone ($this, [
'name' => null,
'adjacent' => $this->adjacent?->withoutName(),
'children' => $this->mapChildren(fn(Result $r) => $r->name === $this->name ? $r->withoutName() : $r),
'children' => $this->mapChildrenIf(
fn(Result $child) => $child->name === $this->name,
static fn(Result $child) => $child->withoutName(),
),
]);
}

Expand All @@ -164,7 +167,10 @@ public function withName(Name $name): self
'name' => $name,
'hasPrecedentName' => $this->path === null,
'adjacent' => $this->adjacent?->withName($name),
'children' => $this->mapChildren(static fn(Result $r) => $r->name === null ? $r->withName($name) : $r),
'children' => $this->mapChildrenIf(
static fn(Result $child) => $child->name === null,
static fn(Result $child) => $child->withName($name),
),
]);
}

Expand All @@ -185,6 +191,18 @@ public function withAdjacent(Result $adjacent): self
return clone($this, ['adjacent' => $adjacent]);
}

public function withInput(mixed $input): self
{
return clone($this, [
'input' => $input,
'adjacent' => $this->adjacent?->withInput($input),
'children' => $this->mapChildrenIf(
fn(Result $child) => $child->input === $this->input && $child->path === $this->path,
static fn(Result $child) => $child->withInput($input),
),
]);
}

public function withToggledValidation(): self
{
return clone($this, [
Expand Down Expand Up @@ -227,4 +245,14 @@ private function mapChildren(callable $callback): array
{
return $this->children === [] ? [] : array_map($callback, $this->children);
}

/** @return array<Result> */
private function mapChildrenIf(callable $condition, callable $callback): array
{
if ($this->children === []) {
return [];
}

return array_map(static fn(self $child) => $condition($child) ? $callback($child) : $child, $this->children);
}
}
47 changes: 47 additions & 0 deletions src/Validators/Masked.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation\Validators;

use Attribute;
use Respect\StringFormatter\InvalidFormatterException;
use Respect\StringFormatter\MaskFormatter;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Result;
use Respect\Validation\Validator;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final readonly class Masked implements Validator
{
private MaskFormatter $maskFormatter;

public function __construct(
private string $range,
private Validator $validator,
private string $replacement = '*',
) {
try {
$this->maskFormatter = new MaskFormatter($this->range, $this->replacement);
} catch (InvalidFormatterException $exception) {
throw new InvalidValidatorException($exception->getMessage());
}
}

public function evaluate(mixed $input): Result
{
$stringVal = new StringVal();
$stringValResult = $stringVal->evaluate($input);
if (!$stringValResult->hasPassed) {
return $stringValResult->withNameFrom($this->validator)->withIdFrom($this->validator);
}

return $this->validator->evaluate($input)->withInput($this->maskFormatter->format((string) $input));
}
}
Loading