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
16 changes: 15 additions & 1 deletion .cursor/skills/validation-add-constraint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ Follow this workflow when adding a new constraint. Mandatory steps: constraint i

For **exported Go names** (functions, types, errors, message constants), follow **[Code Review Comments](https://go.dev/wiki/CodeReviewComments)** — especially **Initialisms** (e.g. `CIDR`, `URL`, not `Cidr`, `Url`). See the **`golang-code-review-comments`** skill in this repo for a short checklist and link.

### Naming: `it` package constructors (declarative style)

Constraint entry points in **`it`** should read as **declarative requirements** on the value, aligned with existing APIs:

- Prefer **`Has…`** for “this value must have / satisfy property X” (e.g. `HasMinLength`, `HasNoSuspiciousCharacters`).
- Prefer **`Is…`** for type or format predicates (e.g. `IsEmail`, `IsUUID`, `IsNotBlank`).
- Prefer **`Not…`** for explicit negation of another constraint’s wording when that matches Symfony or existing library names (e.g. `NotBlank`).

When a Symfony constraint name is `NoXxx` or `NotXxx`, map it to Go as **`HasNoXxx`** (or keep **`NotXxx`** if the library already uses that pattern for the same idea) so call sites look like natural rules: `validation.String(v, it.HasNoSuspiciousCharacters())`, not `it.NoSuspiciousCharacters()`.

For constraints backed by a **bitmask** of checks in `validate`, expose options on **`it`** as **separate methods** (e.g. `CheckInvisible`, `WithoutMixedNumbers`) so IDEs list discoverable configuration; implement them with `|=` / `&^=` on the mask internally. Do **not** require callers to import `validate` just to choose checks. A boolean `is` helper is optional—omit it if it adds little over `validate.Xxx(...) == nil`.

---

## 1. Message and Error (mandatory)
Expand Down Expand Up @@ -84,7 +96,9 @@ var Messages = map[language.Tag]map[string]catalog.Message{

## 3. Constraint in package `it` (mandatory)

Choose the right file: `it/string.go`, `it/identifiers.go`, `it/web.go`, `it/comparison.go`, `it/basic.go`, `it/iterable.go`, `it/date_time.go`, `it/choice.go`, `it/barcodes.go`.
Choose the right file: `it/string.go`, `it/identifiers.go`, `it/web.go`, `it/comparison.go`, `it/basic.go`, `it/iterable.go`, `it/date_time.go`, `it/choice.go`, `it/barcodes.go`, `it/suspicious.go`.

Follow **[Naming: `it` package constructors](#naming-it-package-constructors-declarative-style)** for the exported constructor name.

### 3.1 Simple string constraint (func(string) bool)

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- IBAN validation: `it.IsIBAN()`, `validate.IBAN`, `is.IBAN`, with `validation.ErrInvalidIBAN` / `message.InvalidIBAN` and English and Russian translations (behavior aligned with Symfony `Iban`; country patterns from Symfony 7.2 `IbanValidator`).
- **NoSuspiciousCharacters** (spoofing / homoglyph checks): `it.HasNoSuspiciousCharacters()` with `CheckInvisible` / `CheckMixedNumbers` / `CheckHiddenOverlay`, `WithoutInvisible` / `WithoutMixedNumbers` / `WithoutHiddenOverlay` (bitmask-based), locale and single-script restrictions; `validate.NoSuspiciousCharacters`; errors `validation.ErrSuspiciousInvisible`, `ErrSuspiciousMixedNumbers`, `ErrSuspiciousHiddenOverlay`, `ErrSuspiciousCharactersRestriction` and English/Russian messages (behavior inspired by Symfony `NoSuspiciousCharacters`, implemented without CGO; may differ from ICU in edge cases).
- CIDR notation validation: `it.IsCIDR()` with `IPv4Only`, `IPv6Only`, `WithVersion`, `WithNetmaskRange`, and separate invalid vs netmask-range messages; `validate.CIDR`, `validate.CIDRViolationNetmaskBounds`, `is.CIDR`; `validation.ErrInvalidCIDR` / `validation.ErrCIDRNetmaskOutOfRange` and English and Russian translations (behavior aligned with Symfony `Cidr`). Exported names use the **CIDR** initialism per [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments).
- LUHN (mod 10 / Luhn) checksum validation: `it.IsLUHN()`, `validate.LUHN`, `is.LUHN`, with `validation.ErrInvalidLUHN` / `message.InvalidLUHN` and English and Russian translations (behavior aligned with Symfony `Luhn`).
- ISIN (International Securities Identification Number) validation: `it.IsISIN()`, `validate.ISIN`, `is.ISIN`, with `validation.ErrInvalidISIN` / `message.InvalidISIN` and English and Russian translations (behavior aligned with Symfony `Isin`).

### Fixed

- **NoSuspiciousCharacters** locale restriction: decimal digits (**Unicode Nd**) with a non-Common script (e.g. Arabic-Indic digits) are now validated against allowed scripts, not skipped.
- **NoSuspiciousCharacters** locale mapping: script subtags **Hira**, **Kana**, **Hang**, and **Bopo** map to **Hiragana**, **Katakana**, **Hangul**, and **Bopomofo** (e.g. `ja-Hira` with hiragana text).

## [0.19.0] - 2026-02-09

### Added
Expand Down
5 changes: 5 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ var (
ErrTooLowOrEqual = NewError("is too low or equal", message.TooLowOrEqual)
ErrTooManyElements = NewError("too many elements", message.TooManyElements)
ErrTooShort = NewError("is too short", message.TooShort)

ErrSuspiciousInvisible = NewError("suspicious invisible characters", message.SuspiciousInvisible)
ErrSuspiciousMixedNumbers = NewError("suspicious mixed numbers", message.SuspiciousMixedNumbers)
ErrSuspiciousHiddenOverlay = NewError("suspicious hidden overlay", message.SuspiciousHiddenOverlay)
ErrSuspiciousCharactersRestriction = NewError("suspicious characters restriction", message.SuspiciousCharactersRestriction)
)

// Error is a base type for static validation error used as an underlying error for [Violation].
Expand Down
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -979,3 +979,17 @@ func ExampleIPConstraint_DenyIP() {
// Output:
// violation: "This IP address is prohibited to use."
}

func ExampleHasNoSuspiciousCharacters_valid() {
err := validator.Validate(context.Background(), validation.String("alice", it.HasNoSuspiciousCharacters()))
fmt.Println(err)
// Output:
// <nil>
}

func ExampleHasNoSuspiciousCharacters_invalid() {
err := validator.Validate(context.Background(), validation.String("a\u200b", it.HasNoSuspiciousCharacters()))
fmt.Println(err)
// Output:
// violation: "Using invisible characters is not allowed."
}
241 changes: 241 additions & 0 deletions it/suspicious.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package it

import (
"context"
"errors"

"github.com/muonsoft/validation"
"github.com/muonsoft/validation/validate"
)

// HasNoSuspiciousCharactersConstraint requires that the string has no common Unicode spoofing patterns
// (invisible/format controls, mixed-script decimal digits, risky combining sequences, optional script/locale rules).
// Empty and nil strings are ignored; combine with [IsNotBlank] for required fields.
//
// Behavior is inspired by Symfony’s [NoSuspiciousCharacters] (ICU Spoofchecker) but implemented without CGO;
// edge cases may differ from ICU.
//
// Configure which checks run with [HasNoSuspiciousCharactersConstraint.CheckInvisible],
// [HasNoSuspiciousCharactersConstraint.CheckMixedNumbers], [HasNoSuspiciousCharactersConstraint.CheckHiddenOverlay]
// and the matching Without* methods (bitmask-based internally). If none of these are called, all standard checks run
// (same as Symfony default).
//
// [NoSuspiciousCharacters]: https://symfony.com/doc/current/reference/constraints/NoSuspiciousCharacters.html
type HasNoSuspiciousCharactersConstraint struct {
isIgnored bool
groups []string

checks uint
checksSet bool

restriction validate.SuspiciousRestriction
locales []string

invisibleErr error
invisibleTemplate string
invisibleParams validation.TemplateParameterList
mixedNumbersErr error
mixedNumbersTemplate string
mixedNumbersParams validation.TemplateParameterList
hiddenOverlayErr error
hiddenOverlayTemplate string
hiddenOverlayParams validation.TemplateParameterList
restrictionErr error
restrictionTemplate string
restrictionParams validation.TemplateParameterList
}

// HasNoSuspiciousCharacters creates a constraint with default checks (invisible, mixed numbers, hidden overlay)
// and no locale/script restriction. Use Check* / Without* methods to customize the check bitmask, and
// [HasNoSuspiciousCharactersConstraint.WithSuspiciousRestriction] / [HasNoSuspiciousCharactersConstraint.WithSuspiciousLocales] for script rules.
func HasNoSuspiciousCharacters() HasNoSuspiciousCharactersConstraint {
return HasNoSuspiciousCharactersConstraint{
restriction: validate.SuspiciousRestrictionNone,
invisibleErr: validation.ErrSuspiciousInvisible,
invisibleTemplate: validation.ErrSuspiciousInvisible.Message(),
mixedNumbersErr: validation.ErrSuspiciousMixedNumbers,
mixedNumbersTemplate: validation.ErrSuspiciousMixedNumbers.Message(),
hiddenOverlayErr: validation.ErrSuspiciousHiddenOverlay,
hiddenOverlayTemplate: validation.ErrSuspiciousHiddenOverlay.Message(),
restrictionErr: validation.ErrSuspiciousCharactersRestriction,
restrictionTemplate: validation.ErrSuspiciousCharactersRestriction.Message(),
}
}

func (c HasNoSuspiciousCharactersConstraint) startExplicitChecks() HasNoSuspiciousCharactersConstraint {
if !c.checksSet {
c.checks = 0
c.checksSet = true
}
return c
}

func (c HasNoSuspiciousCharactersConstraint) startFromDefaultChecks() HasNoSuspiciousCharactersConstraint {
if !c.checksSet {
c.checks = validate.DefaultSuspiciousChecks
c.checksSet = true
}
return c
}

// CheckInvisible enables detection of invisible and format-control characters (bit [validate.CheckSuspiciousInvisible]).
func (c HasNoSuspiciousCharactersConstraint) CheckInvisible() HasNoSuspiciousCharactersConstraint {
c = c.startExplicitChecks()
c.checks |= validate.CheckSuspiciousInvisible
return c
}

// CheckMixedNumbers enables detection of mixed decimal digit scripts (bit [validate.CheckSuspiciousMixedNumbers]).
func (c HasNoSuspiciousCharactersConstraint) CheckMixedNumbers() HasNoSuspiciousCharactersConstraint {
c = c.startExplicitChecks()
c.checks |= validate.CheckSuspiciousMixedNumbers
return c
}

// CheckHiddenOverlay enables detection of risky combining overlay sequences (bit [validate.CheckSuspiciousHiddenOverlay]).
func (c HasNoSuspiciousCharactersConstraint) CheckHiddenOverlay() HasNoSuspiciousCharactersConstraint {
c = c.startExplicitChecks()
c.checks |= validate.CheckSuspiciousHiddenOverlay
return c
}

// WithoutInvisible turns off invisible/format-control detection (clears [validate.CheckSuspiciousInvisible] in the mask).
func (c HasNoSuspiciousCharactersConstraint) WithoutInvisible() HasNoSuspiciousCharactersConstraint {
c = c.startFromDefaultChecks()
c.checks &^= validate.CheckSuspiciousInvisible
return c
}

// WithoutMixedNumbers turns off mixed decimal digit script detection (clears [validate.CheckSuspiciousMixedNumbers] in the mask).
func (c HasNoSuspiciousCharactersConstraint) WithoutMixedNumbers() HasNoSuspiciousCharactersConstraint {
c = c.startFromDefaultChecks()
c.checks &^= validate.CheckSuspiciousMixedNumbers
return c
}

// WithoutHiddenOverlay turns off hidden overlay detection (clears [validate.CheckSuspiciousHiddenOverlay] in the mask).
func (c HasNoSuspiciousCharactersConstraint) WithoutHiddenOverlay() HasNoSuspiciousCharactersConstraint {
c = c.startFromDefaultChecks()
c.checks &^= validate.CheckSuspiciousHiddenOverlay
return c
}

// WithSuspiciousRestriction sets script/locale restriction mode ([validate.SuspiciousRestrictionLocales],
// [validate.SuspiciousRestrictionSingleScript], or [validate.SuspiciousRestrictionNone]).
func (c HasNoSuspiciousCharactersConstraint) WithSuspiciousRestriction(r validate.SuspiciousRestriction) HasNoSuspiciousCharactersConstraint {
c.restriction = r
return c
}

// WithSuspiciousLocales sets BCP 47 tags for [validate.SuspiciousRestrictionLocales] (e.g. "en", "ru-RU").
func (c HasNoSuspiciousCharactersConstraint) WithSuspiciousLocales(locales ...string) HasNoSuspiciousCharactersConstraint {
c.locales = append([]string(nil), locales...)
return c
}

// WithInvisibleError overrides the error for invisible/format-control detection.
func (c HasNoSuspiciousCharactersConstraint) WithInvisibleError(err error) HasNoSuspiciousCharactersConstraint {
c.invisibleErr = err
return c
}

// WithInvisibleMessage sets the violation template for invisible/format-control detection.
func (c HasNoSuspiciousCharactersConstraint) WithInvisibleMessage(template string, parameters ...validation.TemplateParameter) HasNoSuspiciousCharactersConstraint {
c.invisibleTemplate = template
c.invisibleParams = parameters
return c
}

// WithMixedNumbersError overrides the error for mixed decimal digit scripts.
func (c HasNoSuspiciousCharactersConstraint) WithMixedNumbersError(err error) HasNoSuspiciousCharactersConstraint {
c.mixedNumbersErr = err
return c
}

// WithMixedNumbersMessage sets the violation template for mixed decimal digit scripts.
func (c HasNoSuspiciousCharactersConstraint) WithMixedNumbersMessage(template string, parameters ...validation.TemplateParameter) HasNoSuspiciousCharactersConstraint {
c.mixedNumbersTemplate = template
c.mixedNumbersParams = parameters
return c
}

// WithHiddenOverlayError overrides the error for hidden combining overlay sequences.
func (c HasNoSuspiciousCharactersConstraint) WithHiddenOverlayError(err error) HasNoSuspiciousCharactersConstraint {
c.hiddenOverlayErr = err
return c
}

// WithHiddenOverlayMessage sets the violation template for hidden overlay sequences.
func (c HasNoSuspiciousCharactersConstraint) WithHiddenOverlayMessage(template string, parameters ...validation.TemplateParameter) HasNoSuspiciousCharactersConstraint {
c.hiddenOverlayTemplate = template
c.hiddenOverlayParams = parameters
return c
}

// WithRestrictionError overrides the error for locale/script restriction failures.
func (c HasNoSuspiciousCharactersConstraint) WithRestrictionError(err error) HasNoSuspiciousCharactersConstraint {
c.restrictionErr = err
return c
}

// WithRestrictionMessage sets the violation template for locale/script restriction failures.
func (c HasNoSuspiciousCharactersConstraint) WithRestrictionMessage(template string, parameters ...validation.TemplateParameter) HasNoSuspiciousCharactersConstraint {
c.restrictionTemplate = template
c.restrictionParams = parameters
return c
}

// When enables conditional validation of this constraint.
func (c HasNoSuspiciousCharactersConstraint) When(condition bool) HasNoSuspiciousCharactersConstraint {
c.isIgnored = !condition
return c
}

// WhenGroups enables conditional validation by validation groups.
func (c HasNoSuspiciousCharactersConstraint) WhenGroups(groups ...string) HasNoSuspiciousCharactersConstraint {
c.groups = groups
return c
}

// ValidateString implements [validation.StringConstraint].
func (c HasNoSuspiciousCharactersConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error {
if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" {
return nil
}
var opts []validate.NoSuspiciousCharactersOption
if c.checksSet {
opts = append(opts, validate.WithSuspiciousChecks(c.checks))
}
opts = append(opts, validate.WithSuspiciousRestriction(c.restriction))
if len(c.locales) > 0 {
opts = append(opts, validate.WithSuspiciousLocales(c.locales...))
}
err := validate.NoSuspiciousCharacters(*value, opts...)
if err == nil {
return nil
}
tmpl, params, verr := c.templateForSuspiciousValidateError(err)
return validator.BuildViolation(ctx, verr, tmpl).
WithParameters(
params.Prepend(
validation.TemplateParameter{Key: "{{ value }}", Value: *value},
)...,
).
Create()
}

func (c HasNoSuspiciousCharactersConstraint) templateForSuspiciousValidateError(err error) (string, validation.TemplateParameterList, error) {
if errors.Is(err, validate.ErrSuspiciousInvisible) {
return c.invisibleTemplate, c.invisibleParams, c.invisibleErr
}
if errors.Is(err, validate.ErrSuspiciousMixedNumbers) {
return c.mixedNumbersTemplate, c.mixedNumbersParams, c.mixedNumbersErr
}
if errors.Is(err, validate.ErrSuspiciousHiddenOverlay) {
return c.hiddenOverlayTemplate, c.hiddenOverlayParams, c.hiddenOverlayErr
}
if errors.Is(err, validate.ErrSuspiciousRestriction) {
return c.restrictionTemplate, c.restrictionParams, c.restrictionErr
}
return validation.ErrNotValid.Message(), nil, validation.ErrNotValid
}
6 changes: 6 additions & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,10 @@ const (
TooLowOrEqual = "This value should be greater than or equal to {{ comparedValue }}."
TooManyElements = "This collection should contain {{ limit }} element(s) or less."
TooShort = "This value is too short. It should have {{ limit }} character(s) or more."

// NoSuspiciousCharacters (Symfony Validator wording).
SuspiciousInvisible = "Using invisible characters is not allowed."
SuspiciousMixedNumbers = "Mixing numbers from different scripts is not allowed."
SuspiciousHiddenOverlay = "Using hidden overlay characters is not allowed."
SuspiciousCharactersRestriction = "This value contains characters that are not allowed by the current restriction level."
)
Loading
Loading