From 97a3b6dac2959e75df21759e3ba4e74244317d76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Apr 2026 06:41:21 +0000 Subject: [PATCH 1/5] Add NoSuspiciousCharacters constraint (spoofing checks) Implement it.NoSuspiciousCharacters with Symfony-aligned check flags, optional locale and single-script restrictions, validate/is helpers, messages and translations, tests and examples. Behavior approximates Symfony ICU Spoofchecker without CGO; document differences in godoc. Co-authored-by: Igor Lazarev --- CHANGELOG.md | 1 + errors.go | 5 + is/example_test.go | 8 + is/suspicious.go | 8 + it/example_test.go | 14 + it/suspicious.go | 183 +++++++++++ message/messages.go | 6 + message/translations/english/messages.go | 54 ++-- message/translations/russian/messages.go | 54 ++-- test/constraints_suspicious_cases_test.go | 76 +++++ test/constraints_test.go | 1 + validate/example_test.go | 10 + validate/suspicious.go | 368 ++++++++++++++++++++++ validate/suspicious_test.go | 51 +++ 14 files changed, 789 insertions(+), 50 deletions(-) create mode 100644 is/suspicious.go create mode 100644 it/suspicious.go create mode 100644 test/constraints_suspicious_cases_test.go create mode 100644 validate/suspicious.go create mode 100644 validate/suspicious_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f23a0d..d4ffa39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **NoSuspiciousCharacters** (spoofing / homoglyph checks): `it.NoSuspiciousCharacters()` with optional checks, locale script restriction, and single-script restriction; `validate.NoSuspiciousCharacters`, `is.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`). diff --git a/errors.go b/errors.go index 6ed9fe3..a9cdc49 100644 --- a/errors.go +++ b/errors.go @@ -63,6 +63,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]. diff --git a/is/example_test.go b/is/example_test.go index 2c5c9ca..88b6d53 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -304,3 +304,11 @@ func ExampleUUID() { // false // true } + +func ExampleNoSuspiciousCharacters() { + fmt.Println(is.NoSuspiciousCharacters("ok")) + fmt.Println(is.NoSuspiciousCharacters("a\u200b")) + // Output: + // true + // false +} diff --git a/is/suspicious.go b/is/suspicious.go new file mode 100644 index 0000000..661a97d --- /dev/null +++ b/is/suspicious.go @@ -0,0 +1,8 @@ +package is + +import "github.com/muonsoft/validation/validate" + +// NoSuspiciousCharacters reports whether value passes the same checks as [validate.NoSuspiciousCharacters]. +func NoSuspiciousCharacters(value string, options ...validate.NoSuspiciousCharactersOption) bool { + return validate.NoSuspiciousCharacters(value, options...) == nil +} diff --git a/it/example_test.go b/it/example_test.go index 1b82c1d..fa415ef 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -965,3 +965,17 @@ func ExampleIPConstraint_DenyIP() { // Output: // violation: "This IP address is prohibited to use." } + +func ExampleNoSuspiciousCharacters_valid() { + err := validator.Validate(context.Background(), validation.String("alice", it.NoSuspiciousCharacters())) + fmt.Println(err) + // Output: + // +} + +func ExampleNoSuspiciousCharacters_invalid() { + err := validator.Validate(context.Background(), validation.String("a\u200b", it.NoSuspiciousCharacters())) + fmt.Println(err) + // Output: + // violation: "Using invisible characters is not allowed." +} diff --git a/it/suspicious.go b/it/suspicious.go new file mode 100644 index 0000000..7143990 --- /dev/null +++ b/it/suspicious.go @@ -0,0 +1,183 @@ +package it + +import ( + "context" + "errors" + + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/validate" +) + +// NoSuspiciousCharactersConstraint rejects strings that look like common Unicode spoofing +// (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. +// +// [NoSuspiciousCharacters]: https://symfony.com/doc/current/reference/constraints/NoSuspiciousCharacters.html +type NoSuspiciousCharactersConstraint struct { + isIgnored bool + groups []string + + checks uint + 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 +} + +// NoSuspiciousCharacters creates a constraint with default checks (invisible, mixed numbers, hidden overlay) +// and no locale/script restriction. Use [NoSuspiciousCharactersConstraint.WithChecks], +// [NoSuspiciousCharactersConstraint.WithSuspiciousRestriction], and [NoSuspiciousCharactersConstraint.WithSuspiciousLocales] to customize. +func NoSuspiciousCharacters() NoSuspiciousCharactersConstraint { + return NoSuspiciousCharactersConstraint{ + checks: validate.DefaultSuspiciousChecks, + 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(), + } +} + +// WithChecks sets the check bitmask ([validate.CheckSuspiciousInvisible], [validate.CheckSuspiciousMixedNumbers], +// [validate.CheckSuspiciousHiddenOverlay]). Use 0 to disable all checks (only restriction rules still apply if set). +func (c NoSuspiciousCharactersConstraint) WithChecks(checks uint) NoSuspiciousCharactersConstraint { + c.checks = checks + return c +} + +// WithSuspiciousRestriction sets script/locale restriction mode ([validate.SuspiciousRestrictionLocales], +// [validate.SuspiciousRestrictionSingleScript], or [validate.SuspiciousRestrictionNone]). +func (c NoSuspiciousCharactersConstraint) WithSuspiciousRestriction(r validate.SuspiciousRestriction) NoSuspiciousCharactersConstraint { + c.restriction = r + return c +} + +// WithSuspiciousLocales sets BCP 47 tags for [validate.SuspiciousRestrictionLocales] (e.g. "en", "ru-RU"). +func (c NoSuspiciousCharactersConstraint) WithSuspiciousLocales(locales ...string) NoSuspiciousCharactersConstraint { + c.locales = append([]string(nil), locales...) + return c +} + +// WithInvisibleError overrides the error for invisible/format-control detection. +func (c NoSuspiciousCharactersConstraint) WithInvisibleError(err error) NoSuspiciousCharactersConstraint { + c.invisibleErr = err + return c +} + +// WithInvisibleMessage sets the violation template for invisible/format-control detection. +func (c NoSuspiciousCharactersConstraint) WithInvisibleMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { + c.invisibleTemplate = template + c.invisibleParams = parameters + return c +} + +// WithMixedNumbersError overrides the error for mixed decimal digit scripts. +func (c NoSuspiciousCharactersConstraint) WithMixedNumbersError(err error) NoSuspiciousCharactersConstraint { + c.mixedNumbersErr = err + return c +} + +// WithMixedNumbersMessage sets the violation template for mixed decimal digit scripts. +func (c NoSuspiciousCharactersConstraint) WithMixedNumbersMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { + c.mixedNumbersTemplate = template + c.mixedNumbersParams = parameters + return c +} + +// WithHiddenOverlayError overrides the error for hidden combining overlay sequences. +func (c NoSuspiciousCharactersConstraint) WithHiddenOverlayError(err error) NoSuspiciousCharactersConstraint { + c.hiddenOverlayErr = err + return c +} + +// WithHiddenOverlayMessage sets the violation template for hidden overlay sequences. +func (c NoSuspiciousCharactersConstraint) WithHiddenOverlayMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { + c.hiddenOverlayTemplate = template + c.hiddenOverlayParams = parameters + return c +} + +// WithRestrictionError overrides the error for locale/script restriction failures. +func (c NoSuspiciousCharactersConstraint) WithRestrictionError(err error) NoSuspiciousCharactersConstraint { + c.restrictionErr = err + return c +} + +// WithRestrictionMessage sets the violation template for locale/script restriction failures. +func (c NoSuspiciousCharactersConstraint) WithRestrictionMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { + c.restrictionTemplate = template + c.restrictionParams = parameters + return c +} + +// When enables conditional validation of this constraint. +func (c NoSuspiciousCharactersConstraint) When(condition bool) NoSuspiciousCharactersConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups enables conditional validation by validation groups. +func (c NoSuspiciousCharactersConstraint) WhenGroups(groups ...string) NoSuspiciousCharactersConstraint { + c.groups = groups + return c +} + +// ValidateString implements [validation.StringConstraint]. +func (c NoSuspiciousCharactersConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" { + return nil + } + opts := []validate.NoSuspiciousCharactersOption{ + validate.WithSuspiciousChecks(c.checks), + 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 NoSuspiciousCharactersConstraint) 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 +} diff --git a/message/messages.go b/message/messages.go index e848304..3debd52 100644 --- a/message/messages.go +++ b/message/messages.go @@ -81,4 +81,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." ) diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 1823983..1360675 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -75,30 +75,34 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.TooLong: plural.Selectf(1, "", plural.One, "This value is too long. It should have {{ limit }} character or less.", plural.Other, "This value is too long. It should have {{ limit }} characters or less."), - message.NotNil: catalog.String(message.NotNil), - message.NoSuchChoice: catalog.String(message.NoSuchChoice), - message.IsBlank: catalog.String(message.IsBlank), - message.IsEqual: catalog.String(message.IsEqual), - message.NotInRange: catalog.String(message.NotInRange), - message.NotInteger: catalog.String(message.NotInteger), - message.NotNegative: catalog.String(message.NotNegative), - message.NotNegativeOrZero: catalog.String(message.NotNegativeOrZero), - message.IsNil: catalog.String(message.IsNil), - message.NotNumeric: catalog.String(message.NotNumeric), - message.NotPositive: catalog.String(message.NotPositive), - message.NotPositiveOrZero: catalog.String(message.NotPositiveOrZero), - message.NotUnique: catalog.String(message.NotUnique), - message.NotValid: catalog.String(message.NotValid), - message.ProhibitedIP: catalog.String(message.ProhibitedIP), - message.ProhibitedURL: catalog.String(message.ProhibitedURL), - message.TooEarly: catalog.String(message.TooEarly), - message.TooEarlyOrEqual: catalog.String(message.TooEarlyOrEqual), - message.TooHigh: catalog.String(message.TooHigh), - message.TooHighOrEqual: catalog.String(message.TooHighOrEqual), - message.TooLate: catalog.String(message.TooLate), - message.TooLateOrEqual: catalog.String(message.TooLateOrEqual), - message.TooLow: catalog.String(message.TooLow), - message.TooLowOrEqual: catalog.String(message.TooLowOrEqual), - message.NotTrue: catalog.String(message.NotTrue), + message.NotNil: catalog.String(message.NotNil), + message.NoSuchChoice: catalog.String(message.NoSuchChoice), + message.IsBlank: catalog.String(message.IsBlank), + message.IsEqual: catalog.String(message.IsEqual), + message.NotInRange: catalog.String(message.NotInRange), + message.NotInteger: catalog.String(message.NotInteger), + message.NotNegative: catalog.String(message.NotNegative), + message.NotNegativeOrZero: catalog.String(message.NotNegativeOrZero), + message.IsNil: catalog.String(message.IsNil), + message.NotNumeric: catalog.String(message.NotNumeric), + message.NotPositive: catalog.String(message.NotPositive), + message.NotPositiveOrZero: catalog.String(message.NotPositiveOrZero), + message.NotUnique: catalog.String(message.NotUnique), + message.NotValid: catalog.String(message.NotValid), + message.ProhibitedIP: catalog.String(message.ProhibitedIP), + message.ProhibitedURL: catalog.String(message.ProhibitedURL), + message.TooEarly: catalog.String(message.TooEarly), + message.TooEarlyOrEqual: catalog.String(message.TooEarlyOrEqual), + message.TooHigh: catalog.String(message.TooHigh), + message.TooHighOrEqual: catalog.String(message.TooHighOrEqual), + message.TooLate: catalog.String(message.TooLate), + message.TooLateOrEqual: catalog.String(message.TooLateOrEqual), + message.TooLow: catalog.String(message.TooLow), + message.TooLowOrEqual: catalog.String(message.TooLowOrEqual), + message.NotTrue: catalog.String(message.NotTrue), + message.SuspiciousInvisible: catalog.String(message.SuspiciousInvisible), + message.SuspiciousMixedNumbers: catalog.String(message.SuspiciousMixedNumbers), + message.SuspiciousHiddenOverlay: catalog.String(message.SuspiciousHiddenOverlay), + message.SuspiciousCharactersRestriction: catalog.String(message.SuspiciousCharactersRestriction), }, } diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 0c9f65c..a019ec7 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -81,30 +81,34 @@ var Messages = map[language.Tag]map[string]catalog.Message{ plural.One, "Значение слишком длинное. Должно быть равно {{ limit }} символу или меньше.", plural.Few, "Значение слишком длинное. Должно быть равно {{ limit }} символам или меньше.", plural.Other, "Значение слишком длинное. Должно быть равно {{ limit }} символам или меньше."), - message.NotNil: catalog.String("Значение должно быть nil."), - message.NoSuchChoice: catalog.String("Выбранное Вами значение недопустимо."), - message.IsBlank: catalog.String("Значение не должно быть пустым."), - message.IsEqual: catalog.String("Значение не должно быть равно {{ comparedValue }}."), - message.NotInRange: catalog.String("Значение должно быть между {{ min }} и {{ max }}."), - message.NotInteger: catalog.String("Это значение не является целым числом."), - message.NotNegative: catalog.String("Значение должно быть отрицательным."), - message.NotNegativeOrZero: catalog.String("Значение должно быть отрицательным или равным нулю."), - message.IsNil: catalog.String("Значение не должно быть nil."), - message.NotNumeric: catalog.String("Это значение не числовое."), - message.NotPositive: catalog.String("Значение должно быть положительным."), - message.NotPositiveOrZero: catalog.String("Значение должно быть положительным или равным нулю."), - message.NotUnique: catalog.String("Эта коллекция должна содержать только уникальные элементы."), - message.NotValid: catalog.String("Значение недопустимо."), - message.ProhibitedIP: catalog.String("Этот IP-адрес запрещено использовать."), - message.ProhibitedURL: catalog.String("Этот URL-адрес запрещено использовать."), - message.TooEarly: catalog.String("Значение должно быть позже чем {{ comparedValue }}."), - message.TooEarlyOrEqual: catalog.String("Значение должно быть позже или равно {{ comparedValue }}."), - message.TooHigh: catalog.String("Значение должно быть меньше чем {{ comparedValue }}."), - message.TooHighOrEqual: catalog.String("Значение должно быть меньше или равно {{ comparedValue }}."), - message.TooLate: catalog.String("Значение должно быть раньше чем {{ comparedValue }}."), - message.TooLateOrEqual: catalog.String("Значение должно быть раньше или равно {{ comparedValue }}."), - message.TooLow: catalog.String("Значение должно быть больше чем {{ comparedValue }}."), - message.TooLowOrEqual: catalog.String("Значение должно быть больше или равно {{ comparedValue }}."), - message.NotTrue: catalog.String("Значение должно быть истинным."), + message.NotNil: catalog.String("Значение должно быть nil."), + message.NoSuchChoice: catalog.String("Выбранное Вами значение недопустимо."), + message.IsBlank: catalog.String("Значение не должно быть пустым."), + message.IsEqual: catalog.String("Значение не должно быть равно {{ comparedValue }}."), + message.NotInRange: catalog.String("Значение должно быть между {{ min }} и {{ max }}."), + message.NotInteger: catalog.String("Это значение не является целым числом."), + message.NotNegative: catalog.String("Значение должно быть отрицательным."), + message.NotNegativeOrZero: catalog.String("Значение должно быть отрицательным или равным нулю."), + message.IsNil: catalog.String("Значение не должно быть nil."), + message.NotNumeric: catalog.String("Это значение не числовое."), + message.NotPositive: catalog.String("Значение должно быть положительным."), + message.NotPositiveOrZero: catalog.String("Значение должно быть положительным или равным нулю."), + message.NotUnique: catalog.String("Эта коллекция должна содержать только уникальные элементы."), + message.NotValid: catalog.String("Значение недопустимо."), + message.ProhibitedIP: catalog.String("Этот IP-адрес запрещено использовать."), + message.ProhibitedURL: catalog.String("Этот URL-адрес запрещено использовать."), + message.TooEarly: catalog.String("Значение должно быть позже чем {{ comparedValue }}."), + message.TooEarlyOrEqual: catalog.String("Значение должно быть позже или равно {{ comparedValue }}."), + message.TooHigh: catalog.String("Значение должно быть меньше чем {{ comparedValue }}."), + message.TooHighOrEqual: catalog.String("Значение должно быть меньше или равно {{ comparedValue }}."), + message.TooLate: catalog.String("Значение должно быть раньше чем {{ comparedValue }}."), + message.TooLateOrEqual: catalog.String("Значение должно быть раньше или равно {{ comparedValue }}."), + message.TooLow: catalog.String("Значение должно быть больше чем {{ comparedValue }}."), + message.TooLowOrEqual: catalog.String("Значение должно быть больше или равно {{ comparedValue }}."), + message.NotTrue: catalog.String("Значение должно быть истинным."), + message.SuspiciousInvisible: catalog.String("Использование невидимых символов не допускается."), + message.SuspiciousMixedNumbers: catalog.String("Смешивание цифр из разных систем письма не допускается."), + message.SuspiciousHiddenOverlay: catalog.String("Использование скрытых комбинирующих символов не допускается."), + message.SuspiciousCharactersRestriction: catalog.String("Значение содержит символы, не разрешённые текущим уровнем ограничений."), }, } diff --git a/test/constraints_suspicious_cases_test.go b/test/constraints_suspicious_cases_test.go new file mode 100644 index 0000000..1a8a32f --- /dev/null +++ b/test/constraints_suspicious_cases_test.go @@ -0,0 +1,76 @@ +package test + +import ( + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message" + "github.com/muonsoft/validation/validate" +) + +var suspiciousCharactersConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "NoSuspiciousCharacters passes on nil", + isApplicableFor: specificValueTypes(stringType), + constraint: it.NoSuspiciousCharacters(), + assert: assertNoError, + }, + { + name: "NoSuspiciousCharacters passes on empty", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue(""), + constraint: it.NoSuspiciousCharacters(), + assert: assertNoError, + }, + { + name: "NoSuspiciousCharacters passes on ASCII", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("symfony"), + constraint: it.NoSuspiciousCharacters(), + assert: assertNoError, + }, + { + name: "NoSuspiciousCharacters violation on zero-width space", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("a\u200b"), + constraint: it.NoSuspiciousCharacters(), + assert: assertHasOneViolation(validation.ErrSuspiciousInvisible, message.SuspiciousInvisible), + }, + { + name: "NoSuspiciousCharacters violation on mixed decimal digits", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("8৪"), + constraint: it.NoSuspiciousCharacters(), + assert: assertHasOneViolation(validation.ErrSuspiciousMixedNumbers, message.SuspiciousMixedNumbers), + }, + { + name: "NoSuspiciousCharacters violation on hidden overlay", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("i\u0307"), + constraint: it.NoSuspiciousCharacters(), + assert: assertHasOneViolation(validation.ErrSuspiciousHiddenOverlay, message.SuspiciousHiddenOverlay), + }, + { + name: "NoSuspiciousCharacters single-script restriction", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("a\u0430"), + constraint: it.NoSuspiciousCharacters(). + WithSuspiciousRestriction(validate.SuspiciousRestrictionSingleScript), + assert: assertHasOneViolation(validation.ErrSuspiciousCharactersRestriction, message.SuspiciousCharactersRestriction), + }, + { + name: "NoSuspiciousCharacters locale restriction", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("πει"), + constraint: it.NoSuspiciousCharacters(). + WithSuspiciousRestriction(validate.SuspiciousRestrictionLocales). + WithSuspiciousLocales("en"), + assert: assertHasOneViolation(validation.ErrSuspiciousCharactersRestriction, message.SuspiciousCharactersRestriction), + }, + { + name: "NoSuspiciousCharacters When false ignores violation", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("a\u200b"), + constraint: it.NoSuspiciousCharacters().When(false), + assert: assertNoError, + }, +} diff --git a/test/constraints_test.go b/test/constraints_test.go index 6bc5051..fc157b6 100644 --- a/test/constraints_test.go +++ b/test/constraints_test.go @@ -83,6 +83,7 @@ var validateTestCases = mergeTestCases( numericConstraintTestCases, rangeComparisonTestCases, regexConstraintTestCases, + suspiciousCharactersConstraintTestCases, timeComparisonTestCases, urlConstraintTestCases, ) diff --git a/validate/example_test.go b/validate/example_test.go index 77d875e..ba1c1f4 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -201,3 +201,13 @@ func ExampleUUID() { // too short // } + +func ExampleNoSuspiciousCharacters() { + fmt.Println(validate.NoSuspiciousCharacters("ok")) + fmt.Println(validate.NoSuspiciousCharacters("8৪")) + fmt.Println(validate.NoSuspiciousCharacters("8৪", validate.WithSuspiciousChecks(0))) + // Output: + // + // suspicious mixed digit scripts + // +} diff --git a/validate/suspicious.go b/validate/suspicious.go new file mode 100644 index 0000000..0ad238d --- /dev/null +++ b/validate/suspicious.go @@ -0,0 +1,368 @@ +package validate + +import ( + "errors" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/language" +) + +// Unicode script names from [unicode.Scripts] map keys (for goconst / clarity). +const ( + unicodeScriptCommon = "Common" + unicodeScriptInherited = "Inherited" +) + +// Suspicious check flags mirror Symfony NoSuspiciousCharacters bit values for API familiarity. +// See https://symfony.com/doc/current/reference/constraints/NoSuspiciousCharacters.html +const ( + CheckSuspiciousInvisible uint = 32 // CHECK_INVISIBLE + CheckSuspiciousMixedNumbers uint = 128 // CHECK_MIXED_NUMBERS + CheckSuspiciousHiddenOverlay uint = 256 // CHECK_HIDDEN_OVERLAY +) + +// DefaultSuspiciousChecks runs all standard spoofing checks (invisible, mixed digit scripts, hidden overlay). +const DefaultSuspiciousChecks = CheckSuspiciousInvisible | CheckSuspiciousMixedNumbers | CheckSuspiciousHiddenOverlay + +// Restriction for script/locale-related validation (approximation of Symfony restriction levels). +type SuspiciousRestriction int + +const ( + // SuspiciousRestrictionNone disables locale/script checks (like RESTRICTION_LEVEL_NONE). + SuspiciousRestrictionNone SuspiciousRestriction = iota + // SuspiciousRestrictionLocales restricts letters/marks to scripts associated with [NoSuspiciousCharactersOptions.Locales] + // (like a moderate locale allow-list). When Locales is empty, [language.English] is used. + SuspiciousRestrictionLocales + // SuspiciousRestrictionSingleScript requires at most one non-Common, non-Inherited script among letters/marks/numbers. + SuspiciousRestrictionSingleScript +) + +var ( + // ErrSuspiciousInvisible is returned when invisible or format-control characters are present. + ErrSuspiciousInvisible = errors.New("suspicious invisible characters") + // ErrSuspiciousMixedNumbers is returned when decimal digits from more than one numbering system are mixed. + ErrSuspiciousMixedNumbers = errors.New("suspicious mixed digit scripts") + // ErrSuspiciousHiddenOverlay is returned for combining sequences that can hide in the preceding glyph (e.g. Latin i + U+0307). + ErrSuspiciousHiddenOverlay = errors.New("suspicious hidden overlay") + // ErrSuspiciousRestriction is returned when a rune’s script is not allowed for the configured locales or single-script rule. + ErrSuspiciousRestriction = errors.New("suspicious script restriction") +) + +// NoSuspiciousCharactersOptions configures [NoSuspiciousCharacters]. +type NoSuspiciousCharactersOptions struct { + Checks uint + checksSet bool + Restriction SuspiciousRestriction + Locales []string +} + +// NoSuspiciousCharactersOption mutates [NoSuspiciousCharactersOptions]. +type NoSuspiciousCharactersOption func(*NoSuspiciousCharactersOptions) + +// WithSuspiciousChecks sets the bitmask of checks (Symfony-compatible values). Zero means “use default all checks”. +func WithSuspiciousChecks(checks uint) NoSuspiciousCharactersOption { + return func(o *NoSuspiciousCharactersOptions) { + o.Checks = checks + o.checksSet = true + } +} + +// WithSuspiciousRestriction sets locale/script restriction mode. +func WithSuspiciousRestriction(r SuspiciousRestriction) NoSuspiciousCharactersOption { + return func(o *NoSuspiciousCharactersOptions) { + o.Restriction = r + } +} + +// WithSuspiciousLocales sets BCP 47 locale tags used for [SuspiciousRestrictionLocales] (e.g. "en", "en-US"). +func WithSuspiciousLocales(locales ...string) NoSuspiciousCharactersOption { + return func(o *NoSuspiciousCharactersOptions) { + o.Locales = append([]string(nil), locales...) + } +} + +// NoSuspiciousCharacters reports whether value is free of common spoofing patterns (homoglyphs, invisible characters, +// mixed-script digits, risky combining sequences). Empty and whitespace-only strings are valid. +// +// Semantics are inspired by Symfony’s NoSuspiciousCharacters (ICU Spoofchecker) but implemented with the Go standard +// library and [golang.org/x/text/language] only; results may differ from ICU for edge cases. +// +// Possible errors: +// - [ErrSuspiciousInvisible] +// - [ErrSuspiciousMixedNumbers] +// - [ErrSuspiciousHiddenOverlay] +// - [ErrSuspiciousRestriction] +func NoSuspiciousCharacters(value string, options ...NoSuspiciousCharactersOption) error { + opts := NoSuspiciousCharactersOptions{} + for _, opt := range options { + opt(&opts) + } + if !opts.checksSet { + opts.Checks = DefaultSuspiciousChecks + } + if !utf8.ValidString(value) { + return ErrSuspiciousInvisible + } + if strings.TrimSpace(value) == "" { + return nil + } + rs := []rune(value) + if err := applySuspiciousCheckBitmask(rs, opts.Checks); err != nil { + return err + } + return applySuspiciousRestriction(rs, opts.Restriction, opts.Locales) +} + +func applySuspiciousCheckBitmask(rs []rune, checks uint) error { + if checks&CheckSuspiciousInvisible != 0 { + if err := checkSuspiciousInvisible(rs); err != nil { + return err + } + } + if checks&CheckSuspiciousMixedNumbers != 0 { + if err := checkSuspiciousMixedNumbers(rs); err != nil { + return err + } + } + if checks&CheckSuspiciousHiddenOverlay != 0 { + if err := checkSuspiciousHiddenOverlay(rs); err != nil { + return err + } + } + return nil +} + +func applySuspiciousRestriction(rs []rune, r SuspiciousRestriction, locales []string) error { + switch r { + case SuspiciousRestrictionLocales: + loc := locales + if len(loc) == 0 { + loc = []string{"en"} + } + return checkSuspiciousLocales(rs, loc) + case SuspiciousRestrictionSingleScript: + return checkSuspiciousSingleScript(rs) + default: + return nil + } +} + +func checkSuspiciousInvisible(runes []rune) error { + for _, r := range runes { + if r == 0 { + return ErrSuspiciousInvisible + } + // Category Cf contains many invisible / format controls used in spoofing (ZW*, BOM, bidi overrides, etc.). + if unicode.Is(unicode.Cf, r) { + return ErrSuspiciousInvisible + } + // Line and paragraph separators are often non-obvious in UI. + if unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) { + return ErrSuspiciousInvisible + } + } + return nil +} + +func checkSuspiciousMixedNumbers(runes []rune) error { + // ASCII and many “European” digits have Unicode script Common; other decimal digits + // belong to a specific script (e.g. Bengali, Arabic). Mixing those buckets matches + // Symfony’s “mixed numbering systems” intent. + var hasCommonDigit bool + var scriptDigit string + for _, r := range runes { + if !unicode.Is(unicode.Nd, r) { + continue + } + scr := scriptNameForRune(r) + if scr == "" || scr == unicodeScriptCommon || scr == unicodeScriptInherited { + hasCommonDigit = true + if scriptDigit != "" { + return ErrSuspiciousMixedNumbers + } + continue + } + if scriptDigit == "" { + scriptDigit = scr + if hasCommonDigit { + return ErrSuspiciousMixedNumbers + } + continue + } + if scr != scriptDigit { + return ErrSuspiciousMixedNumbers + } + } + return nil +} + +func checkSuspiciousHiddenOverlay(runes []rune) error { + for i := 1; i < len(runes); i++ { + prev, cur := runes[i-1], runes[i] + if cur != '\u0307' { + continue + } + // Latin small i/j/l + combining dot above (homograph for dotted Latin letters). + if prev == 'i' || prev == 'j' || prev == 'l' { + return ErrSuspiciousHiddenOverlay + } + } + return nil +} + +// iso15924ToUnicodeScript maps BCP 47 / ISO 15924 script codes (from [language.Tag.Script]) +// to keys in [unicode.Scripts]. Composite tags like Jpan/Kore expand to multiple scripts. +var iso15924ToUnicodeScripts = map[string][]string{ + "Latn": {"Latin"}, + "Cyrl": {"Cyrillic"}, + "Grek": {"Greek"}, + "Arab": {"Arabic"}, + "Hebr": {"Hebrew"}, + "Thai": {"Thai"}, + "Deva": {"Devanagari"}, + "Beng": {"Bengali"}, + "Guru": {"Gurmukhi"}, + "Gujr": {"Gujarati"}, + "Orya": {"Oriya"}, + "Taml": {"Tamil"}, + "Telu": {"Telugu"}, + "Knda": {"Kannada"}, + "Mlym": {"Malayalam"}, + "Sinh": {"Sinhala"}, + "Laoo": {"Lao"}, + "Tibt": {"Tibetan"}, + "Mymr": {"Myanmar"}, + "Geor": {"Georgian"}, + "Ethi": {"Ethiopic"}, + "Cher": {"Cherokee"}, + "Cans": {"Canadian_Aboriginal"}, + "Khmr": {"Khmer"}, + "Hans": {"Han"}, + "Hant": {"Han"}, + "Hani": {"Han"}, + "Jpan": {"Hiragana", "Katakana", "Han"}, + "Kore": {"Hangul", "Han"}, +} + +func unicodeRangeTablesForISO15924(code string) []*unicode.RangeTable { + if code == "" || code == "Zzzz" { + return nil + } + if names, ok := iso15924ToUnicodeScripts[code]; ok { + var out []*unicode.RangeTable + for _, n := range names { + if rt, ok := unicode.Scripts[n]; ok { + out = append(out, rt) + } + } + return out + } + if rt, ok := unicode.Scripts[code]; ok { + return []*unicode.RangeTable{rt} + } + return nil +} + +func buildLocaleAllowedTables(locales []string) []*unicode.RangeTable { + var allowed []*unicode.RangeTable + seen := make(map[*unicode.RangeTable]struct{}) + for _, loc := range locales { + tag, err := language.Parse(loc) + if err != nil { + continue + } + scr, _ := tag.Script() + for _, rt := range unicodeRangeTablesForISO15924(scr.String()) { + if _, dup := seen[rt]; dup { + continue + } + seen[rt] = struct{}{} + allowed = append(allowed, rt) + } + } + if latin, ok := unicode.Scripts["Latin"]; ok { + if _, dup := seen[latin]; !dup { + seen[latin] = struct{}{} + allowed = append(allowed, latin) + } + } + return allowed +} + +func runeInAnyScriptTable(r rune, tables []*unicode.RangeTable) bool { + for _, rt := range tables { + if unicode.Is(rt, r) { + return true + } + } + return false +} + +func checkSuspiciousLocales(runes []rune, locales []string) error { + allowed := buildLocaleAllowedTables(locales) + if len(allowed) == 0 { + return nil + } + for _, r := range runes { + if !needsScriptCheck(r) { + continue + } + if unicodeIsOnlyCommonInherited(r) { + continue + } + if !runeInAnyScriptTable(r, allowed) { + return ErrSuspiciousRestriction + } + } + return nil +} + +func unicodeIsOnlyCommonInherited(r rune) bool { + scr := scriptNameForRune(r) + return scr == "" || scr == unicodeScriptCommon || scr == unicodeScriptInherited +} + +func checkSuspiciousSingleScript(runes []rune) error { + var primary string + for _, r := range runes { + if !needsScriptCheck(r) && !unicode.Is(unicode.Nd, r) { + continue + } + scr := scriptNameForRune(r) + if scr == "" || scr == unicodeScriptCommon || scr == unicodeScriptInherited { + continue + } + if primary == "" { + primary = scr + continue + } + if scr != primary { + return ErrSuspiciousRestriction + } + } + return nil +} + +func needsScriptCheck(r rune) bool { + if unicode.IsLetter(r) { + return true + } + if unicode.IsMark(r) { + return true + } + if unicode.Is(unicode.Nl, r) || unicode.Is(unicode.No, r) { + return true + } + return false +} + +func scriptNameForRune(r rune) string { + for name, table := range unicode.Scripts { + if unicode.Is(table, r) { + return name + } + } + return "" +} diff --git a/validate/suspicious_test.go b/validate/suspicious_test.go new file mode 100644 index 0000000..7cefdf9 --- /dev/null +++ b/validate/suspicious_test.go @@ -0,0 +1,51 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNoSuspiciousCharacters_invisible(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("hello")) + assert.ErrorIs(t, NoSuspiciousCharacters("a\u200b"), ErrSuspiciousInvisible) + assert.ErrorIs(t, NoSuspiciousCharacters("\ufeffx"), ErrSuspiciousInvisible) +} + +func TestNoSuspiciousCharacters_mixedNumbers(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("123")) + assert.ErrorIs(t, NoSuspiciousCharacters("8৪"), ErrSuspiciousMixedNumbers) + assert.ErrorIs(t, NoSuspiciousCharacters("৪٤"), ErrSuspiciousMixedNumbers) +} + +func TestNoSuspiciousCharacters_hiddenOverlay(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("café")) + assert.ErrorIs(t, NoSuspiciousCharacters("i\u0307"), ErrSuspiciousHiddenOverlay) +} + +func TestNoSuspiciousCharacters_singleScript(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("hello", WithSuspiciousRestriction(SuspiciousRestrictionSingleScript))) + assert.ErrorIs(t, + NoSuspiciousCharacters("a\u0430", WithSuspiciousRestriction(SuspiciousRestrictionSingleScript)), + ErrSuspiciousRestriction, + ) +} + +func TestNoSuspiciousCharacters_locales(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("hello", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("en"))) + assert.NoError(t, NoSuspiciousCharacters("πει", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("el"))) + assert.ErrorIs(t, + NoSuspiciousCharacters("πει", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("en")), + ErrSuspiciousRestriction, + ) +} + +func TestNoSuspiciousCharacters_disableChecks(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("8৪", WithSuspiciousChecks(0))) +} From d9f5bc8f4d88da9e3934108fed6b00b6c705adb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Apr 2026 11:58:17 +0000 Subject: [PATCH 2/5] Rename it constraint to HasNoSuspiciousCharacters Use declarative Has* naming for package it; document convention in validation-add-constraint skill. Add CHANGELOG [Changed] note. Co-authored-by: Igor Lazarev --- .../skills/validation-add-constraint/SKILL.md | 14 +++++- CHANGELOG.md | 6 ++- it/example_test.go | 8 ++-- it/suspicious.go | 44 +++++++++---------- test/constraints_suspicious_cases_test.go | 36 +++++++-------- 5 files changed, 62 insertions(+), 46 deletions(-) diff --git a/.cursor/skills/validation-add-constraint/SKILL.md b/.cursor/skills/validation-add-constraint/SKILL.md index e93d733..484c7ed 100644 --- a/.cursor/skills/validation-add-constraint/SKILL.md +++ b/.cursor/skills/validation-add-constraint/SKILL.md @@ -9,6 +9,16 @@ 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()`. + --- ## 1. Message and Error (mandatory) @@ -84,7 +94,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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 412cb76..a5fda0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,15 @@ 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.NoSuspiciousCharacters()` with optional checks, locale script restriction, and single-script restriction; `validate.NoSuspiciousCharacters`, `is.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). +- **NoSuspiciousCharacters** (spoofing / homoglyph checks): `it.HasNoSuspiciousCharacters()` with optional checks, locale script restriction, and single-script restriction; `validate.NoSuspiciousCharacters`, `is.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`). +### Changed + +- **NoSuspiciousCharacters** (`it`): constructor renamed from `NoSuspiciousCharacters` to `HasNoSuspiciousCharacters`; constraint type renamed to `HasNoSuspiciousCharactersConstraint` (declarative naming, consistent with `HasMinLength` and similar). + ## [0.19.0] - 2026-02-09 ### Added diff --git a/it/example_test.go b/it/example_test.go index e4b46a9..169a1c3 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -980,15 +980,15 @@ func ExampleIPConstraint_DenyIP() { // violation: "This IP address is prohibited to use." } -func ExampleNoSuspiciousCharacters_valid() { - err := validator.Validate(context.Background(), validation.String("alice", it.NoSuspiciousCharacters())) +func ExampleHasNoSuspiciousCharacters_valid() { + err := validator.Validate(context.Background(), validation.String("alice", it.HasNoSuspiciousCharacters())) fmt.Println(err) // Output: // } -func ExampleNoSuspiciousCharacters_invalid() { - err := validator.Validate(context.Background(), validation.String("a\u200b", it.NoSuspiciousCharacters())) +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." diff --git a/it/suspicious.go b/it/suspicious.go index 7143990..66fab57 100644 --- a/it/suspicious.go +++ b/it/suspicious.go @@ -8,7 +8,7 @@ import ( "github.com/muonsoft/validation/validate" ) -// NoSuspiciousCharactersConstraint rejects strings that look like common Unicode spoofing +// 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. // @@ -16,7 +16,7 @@ import ( // edge cases may differ from ICU. // // [NoSuspiciousCharacters]: https://symfony.com/doc/current/reference/constraints/NoSuspiciousCharacters.html -type NoSuspiciousCharactersConstraint struct { +type HasNoSuspiciousCharactersConstraint struct { isIgnored bool groups []string @@ -38,11 +38,11 @@ type NoSuspiciousCharactersConstraint struct { restrictionParams validation.TemplateParameterList } -// NoSuspiciousCharacters creates a constraint with default checks (invisible, mixed numbers, hidden overlay) -// and no locale/script restriction. Use [NoSuspiciousCharactersConstraint.WithChecks], -// [NoSuspiciousCharactersConstraint.WithSuspiciousRestriction], and [NoSuspiciousCharactersConstraint.WithSuspiciousLocales] to customize. -func NoSuspiciousCharacters() NoSuspiciousCharactersConstraint { - return NoSuspiciousCharactersConstraint{ +// HasNoSuspiciousCharacters creates a constraint with default checks (invisible, mixed numbers, hidden overlay) +// and no locale/script restriction. Use [HasNoSuspiciousCharactersConstraint.WithChecks], +// [HasNoSuspiciousCharactersConstraint.WithSuspiciousRestriction], and [HasNoSuspiciousCharactersConstraint.WithSuspiciousLocales] to customize. +func HasNoSuspiciousCharacters() HasNoSuspiciousCharactersConstraint { + return HasNoSuspiciousCharactersConstraint{ checks: validate.DefaultSuspiciousChecks, restriction: validate.SuspiciousRestrictionNone, invisibleErr: validation.ErrSuspiciousInvisible, @@ -58,90 +58,90 @@ func NoSuspiciousCharacters() NoSuspiciousCharactersConstraint { // WithChecks sets the check bitmask ([validate.CheckSuspiciousInvisible], [validate.CheckSuspiciousMixedNumbers], // [validate.CheckSuspiciousHiddenOverlay]). Use 0 to disable all checks (only restriction rules still apply if set). -func (c NoSuspiciousCharactersConstraint) WithChecks(checks uint) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) WithChecks(checks uint) HasNoSuspiciousCharactersConstraint { c.checks = checks return c } // WithSuspiciousRestriction sets script/locale restriction mode ([validate.SuspiciousRestrictionLocales], // [validate.SuspiciousRestrictionSingleScript], or [validate.SuspiciousRestrictionNone]). -func (c NoSuspiciousCharactersConstraint) WithSuspiciousRestriction(r validate.SuspiciousRestriction) NoSuspiciousCharactersConstraint { +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 NoSuspiciousCharactersConstraint) WithSuspiciousLocales(locales ...string) NoSuspiciousCharactersConstraint { +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 NoSuspiciousCharactersConstraint) WithInvisibleError(err error) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) WithInvisibleError(err error) HasNoSuspiciousCharactersConstraint { c.invisibleErr = err return c } // WithInvisibleMessage sets the violation template for invisible/format-control detection. -func (c NoSuspiciousCharactersConstraint) WithInvisibleMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { +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 NoSuspiciousCharactersConstraint) WithMixedNumbersError(err error) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) WithMixedNumbersError(err error) HasNoSuspiciousCharactersConstraint { c.mixedNumbersErr = err return c } // WithMixedNumbersMessage sets the violation template for mixed decimal digit scripts. -func (c NoSuspiciousCharactersConstraint) WithMixedNumbersMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { +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 NoSuspiciousCharactersConstraint) WithHiddenOverlayError(err error) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) WithHiddenOverlayError(err error) HasNoSuspiciousCharactersConstraint { c.hiddenOverlayErr = err return c } // WithHiddenOverlayMessage sets the violation template for hidden overlay sequences. -func (c NoSuspiciousCharactersConstraint) WithHiddenOverlayMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { +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 NoSuspiciousCharactersConstraint) WithRestrictionError(err error) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) WithRestrictionError(err error) HasNoSuspiciousCharactersConstraint { c.restrictionErr = err return c } // WithRestrictionMessage sets the violation template for locale/script restriction failures. -func (c NoSuspiciousCharactersConstraint) WithRestrictionMessage(template string, parameters ...validation.TemplateParameter) NoSuspiciousCharactersConstraint { +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 NoSuspiciousCharactersConstraint) When(condition bool) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) When(condition bool) HasNoSuspiciousCharactersConstraint { c.isIgnored = !condition return c } // WhenGroups enables conditional validation by validation groups. -func (c NoSuspiciousCharactersConstraint) WhenGroups(groups ...string) NoSuspiciousCharactersConstraint { +func (c HasNoSuspiciousCharactersConstraint) WhenGroups(groups ...string) HasNoSuspiciousCharactersConstraint { c.groups = groups return c } // ValidateString implements [validation.StringConstraint]. -func (c NoSuspiciousCharactersConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { +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 } @@ -166,7 +166,7 @@ func (c NoSuspiciousCharactersConstraint) ValidateString(ctx context.Context, va Create() } -func (c NoSuspiciousCharactersConstraint) templateForSuspiciousValidateError(err error) (string, validation.TemplateParameterList, error) { +func (c HasNoSuspiciousCharactersConstraint) templateForSuspiciousValidateError(err error) (string, validation.TemplateParameterList, error) { if errors.Is(err, validate.ErrSuspiciousInvisible) { return c.invisibleTemplate, c.invisibleParams, c.invisibleErr } diff --git a/test/constraints_suspicious_cases_test.go b/test/constraints_suspicious_cases_test.go index 1a8a32f..d285146 100644 --- a/test/constraints_suspicious_cases_test.go +++ b/test/constraints_suspicious_cases_test.go @@ -9,68 +9,68 @@ import ( var suspiciousCharactersConstraintTestCases = []ConstraintValidationTestCase{ { - name: "NoSuspiciousCharacters passes on nil", + name: "HasNoSuspiciousCharacters passes on nil", isApplicableFor: specificValueTypes(stringType), - constraint: it.NoSuspiciousCharacters(), + constraint: it.HasNoSuspiciousCharacters(), assert: assertNoError, }, { - name: "NoSuspiciousCharacters passes on empty", + name: "HasNoSuspiciousCharacters passes on empty", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue(""), - constraint: it.NoSuspiciousCharacters(), + constraint: it.HasNoSuspiciousCharacters(), assert: assertNoError, }, { - name: "NoSuspiciousCharacters passes on ASCII", + name: "HasNoSuspiciousCharacters passes on ASCII", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("symfony"), - constraint: it.NoSuspiciousCharacters(), + constraint: it.HasNoSuspiciousCharacters(), assert: assertNoError, }, { - name: "NoSuspiciousCharacters violation on zero-width space", + name: "HasNoSuspiciousCharacters violation on zero-width space", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("a\u200b"), - constraint: it.NoSuspiciousCharacters(), + constraint: it.HasNoSuspiciousCharacters(), assert: assertHasOneViolation(validation.ErrSuspiciousInvisible, message.SuspiciousInvisible), }, { - name: "NoSuspiciousCharacters violation on mixed decimal digits", + name: "HasNoSuspiciousCharacters violation on mixed decimal digits", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("8৪"), - constraint: it.NoSuspiciousCharacters(), + constraint: it.HasNoSuspiciousCharacters(), assert: assertHasOneViolation(validation.ErrSuspiciousMixedNumbers, message.SuspiciousMixedNumbers), }, { - name: "NoSuspiciousCharacters violation on hidden overlay", + name: "HasNoSuspiciousCharacters violation on hidden overlay", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("i\u0307"), - constraint: it.NoSuspiciousCharacters(), + constraint: it.HasNoSuspiciousCharacters(), assert: assertHasOneViolation(validation.ErrSuspiciousHiddenOverlay, message.SuspiciousHiddenOverlay), }, { - name: "NoSuspiciousCharacters single-script restriction", + name: "HasNoSuspiciousCharacters single-script restriction", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("a\u0430"), - constraint: it.NoSuspiciousCharacters(). + constraint: it.HasNoSuspiciousCharacters(). WithSuspiciousRestriction(validate.SuspiciousRestrictionSingleScript), assert: assertHasOneViolation(validation.ErrSuspiciousCharactersRestriction, message.SuspiciousCharactersRestriction), }, { - name: "NoSuspiciousCharacters locale restriction", + name: "HasNoSuspiciousCharacters locale restriction", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("πει"), - constraint: it.NoSuspiciousCharacters(). + constraint: it.HasNoSuspiciousCharacters(). WithSuspiciousRestriction(validate.SuspiciousRestrictionLocales). WithSuspiciousLocales("en"), assert: assertHasOneViolation(validation.ErrSuspiciousCharactersRestriction, message.SuspiciousCharactersRestriction), }, { - name: "NoSuspiciousCharacters When false ignores violation", + name: "HasNoSuspiciousCharacters When false ignores violation", isApplicableFor: specificValueTypes(stringType), stringValue: stringValue("a\u200b"), - constraint: it.NoSuspiciousCharacters().When(false), + constraint: it.HasNoSuspiciousCharacters().When(false), assert: assertNoError, }, } From c7dc8bc523812696a8e20cd0efc4045a6f2f7d52 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Apr 2026 12:56:06 +0000 Subject: [PATCH 3/5] Changelog: drop spurious Changed for NoSuspiciousCharacters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature is unreleased on this branch; public API is HasNoSuspiciousCharacters from the start—no rename to document under [Unreleased]. Co-authored-by: Igor Lazarev --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fda0d..584a19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). -### Changed - -- **NoSuspiciousCharacters** (`it`): constructor renamed from `NoSuspiciousCharacters` to `HasNoSuspiciousCharacters`; constraint type renamed to `HasNoSuspiciousCharactersConstraint` (declarative naming, consistent with `HasMinLength` and similar). - ## [0.19.0] - 2026-02-09 ### Added From fe8aa8ca3cf031ca6a1cffdbca149a7c13e5b493 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 16:20:17 +0000 Subject: [PATCH 4/5] NoSuspiciousCharacters: drop is helper, it Check*/Without* API, expand tests Remove is.NoSuspiciousCharacters. Replace it.WithChecks with bitmask methods CheckInvisible/MixedNumbers/HiddenOverlay and Without* defaults. Extend validate tests for masks, locales edge cases, helpers, and Zl/Zp/Cf. Update changelog and validation-add-constraint skill. Co-authored-by: Igor Lazarev --- .../skills/validation-add-constraint/SKILL.md | 2 + CHANGELOG.md | 2 +- is/example_test.go | 8 - is/suspicious.go | 8 - it/suspicious.go | 80 +++++++-- test/constraints_suspicious_cases_test.go | 14 ++ validate/suspicious_test.go | 160 +++++++++++++++++- 7 files changed, 244 insertions(+), 30 deletions(-) delete mode 100644 is/suspicious.go diff --git a/.cursor/skills/validation-add-constraint/SKILL.md b/.cursor/skills/validation-add-constraint/SKILL.md index 484c7ed..0aee9b1 100644 --- a/.cursor/skills/validation-add-constraint/SKILL.md +++ b/.cursor/skills/validation-add-constraint/SKILL.md @@ -19,6 +19,8 @@ Constraint entry points in **`it`** should read as **declarative requirements** 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 584a19b..5c5033d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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 optional checks, locale script restriction, and single-script restriction; `validate.NoSuspiciousCharacters`, `is.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). +- **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`). diff --git a/is/example_test.go b/is/example_test.go index 13aee89..0ba3f4c 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -314,11 +314,3 @@ func ExampleUUID() { // false // true } - -func ExampleNoSuspiciousCharacters() { - fmt.Println(is.NoSuspiciousCharacters("ok")) - fmt.Println(is.NoSuspiciousCharacters("a\u200b")) - // Output: - // true - // false -} diff --git a/is/suspicious.go b/is/suspicious.go deleted file mode 100644 index 661a97d..0000000 --- a/is/suspicious.go +++ /dev/null @@ -1,8 +0,0 @@ -package is - -import "github.com/muonsoft/validation/validate" - -// NoSuspiciousCharacters reports whether value passes the same checks as [validate.NoSuspiciousCharacters]. -func NoSuspiciousCharacters(value string, options ...validate.NoSuspiciousCharactersOption) bool { - return validate.NoSuspiciousCharacters(value, options...) == nil -} diff --git a/it/suspicious.go b/it/suspicious.go index 66fab57..25fa3ef 100644 --- a/it/suspicious.go +++ b/it/suspicious.go @@ -15,12 +15,19 @@ import ( // 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 + checks uint + checksSet bool + restriction validate.SuspiciousRestriction locales []string @@ -39,11 +46,10 @@ type HasNoSuspiciousCharactersConstraint struct { } // HasNoSuspiciousCharacters creates a constraint with default checks (invisible, mixed numbers, hidden overlay) -// and no locale/script restriction. Use [HasNoSuspiciousCharactersConstraint.WithChecks], -// [HasNoSuspiciousCharactersConstraint.WithSuspiciousRestriction], and [HasNoSuspiciousCharactersConstraint.WithSuspiciousLocales] to customize. +// 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{ - checks: validate.DefaultSuspiciousChecks, restriction: validate.SuspiciousRestrictionNone, invisibleErr: validation.ErrSuspiciousInvisible, invisibleTemplate: validation.ErrSuspiciousInvisible.Message(), @@ -56,10 +62,61 @@ func HasNoSuspiciousCharacters() HasNoSuspiciousCharactersConstraint { } } -// WithChecks sets the check bitmask ([validate.CheckSuspiciousInvisible], [validate.CheckSuspiciousMixedNumbers], -// [validate.CheckSuspiciousHiddenOverlay]). Use 0 to disable all checks (only restriction rules still apply if set). -func (c HasNoSuspiciousCharactersConstraint) WithChecks(checks uint) HasNoSuspiciousCharactersConstraint { - c.checks = checks +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 } @@ -145,10 +202,11 @@ func (c HasNoSuspiciousCharactersConstraint) ValidateString(ctx context.Context, if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" { return nil } - opts := []validate.NoSuspiciousCharactersOption{ - validate.WithSuspiciousChecks(c.checks), - validate.WithSuspiciousRestriction(c.restriction), + 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...)) } diff --git a/test/constraints_suspicious_cases_test.go b/test/constraints_suspicious_cases_test.go index d285146..cfcd0ab 100644 --- a/test/constraints_suspicious_cases_test.go +++ b/test/constraints_suspicious_cases_test.go @@ -73,4 +73,18 @@ var suspiciousCharactersConstraintTestCases = []ConstraintValidationTestCase{ constraint: it.HasNoSuspiciousCharacters().When(false), assert: assertNoError, }, + { + name: "HasNoSuspiciousCharacters CheckMixedNumbers only ignores invisible", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("a\u200b"), + constraint: it.HasNoSuspiciousCharacters().CheckMixedNumbers(), + assert: assertNoError, + }, + { + name: "HasNoSuspiciousCharacters WithoutMixedNumbers allows mixed digits", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("8৪"), + constraint: it.HasNoSuspiciousCharacters().WithoutMixedNumbers(), + assert: assertNoError, + }, } diff --git a/validate/suspicious_test.go b/validate/suspicious_test.go index 7cefdf9..ce17786 100644 --- a/validate/suspicious_test.go +++ b/validate/suspicious_test.go @@ -1,9 +1,12 @@ package validate import ( + "strings" "testing" + "unicode" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNoSuspiciousCharacters_invisible(t *testing.T) { @@ -11,6 +14,7 @@ func TestNoSuspiciousCharacters_invisible(t *testing.T) { assert.NoError(t, NoSuspiciousCharacters("hello")) assert.ErrorIs(t, NoSuspiciousCharacters("a\u200b"), ErrSuspiciousInvisible) assert.ErrorIs(t, NoSuspiciousCharacters("\ufeffx"), ErrSuspiciousInvisible) + assert.ErrorIs(t, NoSuspiciousCharacters(string([]byte{0xff, 0xfe})), ErrSuspiciousInvisible) } func TestNoSuspiciousCharacters_mixedNumbers(t *testing.T) { @@ -18,12 +22,17 @@ func TestNoSuspiciousCharacters_mixedNumbers(t *testing.T) { assert.NoError(t, NoSuspiciousCharacters("123")) assert.ErrorIs(t, NoSuspiciousCharacters("8৪"), ErrSuspiciousMixedNumbers) assert.ErrorIs(t, NoSuspiciousCharacters("৪٤"), ErrSuspiciousMixedNumbers) + assert.NoError(t, NoSuspiciousCharacters("৪৫")) // same Bengali script + assert.NoError(t, NoSuspiciousCharacters("٤٥")) // same Arabic script digits } func TestNoSuspiciousCharacters_hiddenOverlay(t *testing.T) { t.Parallel() assert.NoError(t, NoSuspiciousCharacters("café")) assert.ErrorIs(t, NoSuspiciousCharacters("i\u0307"), ErrSuspiciousHiddenOverlay) + assert.ErrorIs(t, NoSuspiciousCharacters("j\u0307"), ErrSuspiciousHiddenOverlay) + assert.ErrorIs(t, NoSuspiciousCharacters("l\u0307"), ErrSuspiciousHiddenOverlay) + assert.NoError(t, NoSuspiciousCharacters("x\u0307")) } func TestNoSuspiciousCharacters_singleScript(t *testing.T) { @@ -45,7 +54,154 @@ func TestNoSuspiciousCharacters_locales(t *testing.T) { ) } -func TestNoSuspiciousCharacters_disableChecks(t *testing.T) { +func TestNoSuspiciousCharacters_whitespaceOnlyValid(t *testing.T) { t.Parallel() - assert.NoError(t, NoSuspiciousCharacters("8৪", WithSuspiciousChecks(0))) + assert.NoError(t, NoSuspiciousCharacters(" ")) + assert.NoError(t, NoSuspiciousCharacters("\t\n")) +} + +func TestNoSuspiciousCharacters_restrictionNoneIgnoresLocales(t *testing.T) { + t.Parallel() + // Greek letters should not be restricted when mode is None even if locales are passed. + assert.NoError(t, NoSuspiciousCharacters("πει", WithSuspiciousRestriction(SuspiciousRestrictionNone), WithSuspiciousLocales("en"))) +} + +func TestNoSuspiciousCharacters_localesEmptyDefaultsToEnglish(t *testing.T) { + t.Parallel() + assert.NoError(t, NoSuspiciousCharacters("hello", WithSuspiciousRestriction(SuspiciousRestrictionLocales))) + assert.ErrorIs(t, + NoSuspiciousCharacters("πει", WithSuspiciousRestriction(SuspiciousRestrictionLocales)), + ErrSuspiciousRestriction, + ) +} + +func TestNoSuspiciousCharacters_localesInvalidTagsStillAllowLatin(t *testing.T) { + t.Parallel() + // Unparseable locale tags add no script tables; [buildLocaleAllowedTables] still appends Latin. + assert.NoError(t, NoSuspiciousCharacters("hello", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("not-a-locale-!!!"))) + assert.ErrorIs(t, + NoSuspiciousCharacters("πει", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("not-a-locale-!!!")), + ErrSuspiciousRestriction, + ) +} + +func TestNoSuspiciousCharacters_checkMaskCombinations(t *testing.T) { + t.Parallel() + invis := "a\u200b" + mixed := "8৪" + overlay := "i\u0307" + + cases := []struct { + name string + opts []NoSuspiciousCharactersOption + value string + wantErr error + }{ + {"disable all", []NoSuspiciousCharactersOption{WithSuspiciousChecks(0)}, mixed, nil}, + {"only invisible on invis", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousInvisible)}, invis, ErrSuspiciousInvisible}, + {"only invisible passes clean", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousInvisible)}, "ok", nil}, + {"only invisible ignores mixed", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousInvisible)}, mixed, nil}, + {"only mixed on mixed", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousMixedNumbers)}, mixed, ErrSuspiciousMixedNumbers}, + {"only mixed ignores invis", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousMixedNumbers)}, invis, nil}, + {"only overlay on overlay", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousHiddenOverlay)}, overlay, ErrSuspiciousHiddenOverlay}, + {"invis+mixed", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousInvisible | CheckSuspiciousMixedNumbers)}, invis, ErrSuspiciousInvisible}, + {"invis+mixed second", []NoSuspiciousCharactersOption{WithSuspiciousChecks(CheckSuspiciousInvisible | CheckSuspiciousMixedNumbers)}, mixed, ErrSuspiciousMixedNumbers}, + {"default all hits invis first", nil, invis, ErrSuspiciousInvisible}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := NoSuspiciousCharacters(tc.value, tc.opts...) + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNoSuspiciousCharacters_restrictionWithPartialChecks(t *testing.T) { + t.Parallel() + // Cyrillic not allowed for en; mixed-numbers check off so we reach restriction. + opts := []NoSuspiciousCharactersOption{ + WithSuspiciousChecks(CheckSuspiciousInvisible | CheckSuspiciousHiddenOverlay), + WithSuspiciousRestriction(SuspiciousRestrictionLocales), + WithSuspiciousLocales("en"), + } + assert.ErrorIs(t, NoSuspiciousCharacters("ы", opts...), ErrSuspiciousRestriction) +} + +func TestUnicodeRangeTablesForISO15924(t *testing.T) { + t.Parallel() + assert.Nil(t, unicodeRangeTablesForISO15924("")) + assert.Nil(t, unicodeRangeTablesForISO15924("Zzzz")) + require.NotEmpty(t, unicodeRangeTablesForISO15924("Latn")) + require.NotEmpty(t, unicodeRangeTablesForISO15924("Jpan")) + // Unknown ISO code: if it matches a unicode.Scripts key, return one table. + if rt := unicodeRangeTablesForISO15924("Thai"); len(rt) != 1 { + t.Fatalf("Thai: got %d tables", len(rt)) + } +} + +func TestScriptNameForRune(t *testing.T) { + t.Parallel() + assert.Equal(t, "Latin", scriptNameForRune('a')) + assert.Equal(t, "Greek", scriptNameForRune('π')) + assert.Equal(t, "", scriptNameForRune(-1)) +} + +func TestBuildLocaleAllowedTables_includesLatin(t *testing.T) { + t.Parallel() + tab := buildLocaleAllowedTables([]string{"el"}) + var hasLatin bool + for _, rt := range tab { + if rt == unicode.Scripts["Latin"] { + hasLatin = true + break + } + } + assert.True(t, hasLatin, "Latin should be appended for locale lists") +} + +func TestApplySuspiciousCheckBitmask_emptyMaskNoop(t *testing.T) { + t.Parallel() + rs := []rune("a\u200b") + assert.NoError(t, applySuspiciousCheckBitmask(rs, 0)) +} + +func TestNoSuspiciousCharacters_zlZpInvisible(t *testing.T) { + t.Parallel() + // U+2028 LINE SEPARATOR (Zl), U+2029 PARAGRAPH SEPARATOR (Zp) + assert.ErrorIs(t, NoSuspiciousCharacters("\u2028x"), ErrSuspiciousInvisible) + assert.ErrorIs(t, NoSuspiciousCharacters("\u2029x"), ErrSuspiciousInvisible) +} + +func TestNoSuspiciousCharacters_nulInvisible(t *testing.T) { + t.Parallel() + assert.ErrorIs(t, NoSuspiciousCharacters("a\x00b"), ErrSuspiciousInvisible) +} + +func TestCheckSuspiciousInvisible_bidiFormatCf(t *testing.T) { + t.Parallel() + // U+202E RIGHT-TO-LEFT OVERRIDE is Cf + assert.ErrorIs(t, checkSuspiciousInvisible([]rune("a\u202eb")), ErrSuspiciousInvisible) +} + +func TestNoSuspiciousCharacters_longStringStillScanned(t *testing.T) { + t.Parallel() + s := strings.Repeat("a", 500) + "\u200b" + assert.ErrorIs(t, NoSuspiciousCharacters(s), ErrSuspiciousInvisible) +} + +func TestNeedsScriptCheck_letterNumberOther(t *testing.T) { + t.Parallel() + // U+2160 ROMAN NUMERAL ONE is category Nl; exercise needsScriptCheck Nl branch with locale restriction. + assert.True(t, needsScriptCheck('\u2160')) + assert.NoError(t, NoSuspiciousCharacters("\u2160", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("en"))) +} + +func TestUnicodeRangeTablesForISO15924_unknownCode(t *testing.T) { + t.Parallel() + assert.Nil(t, unicodeRangeTablesForISO15924("Qabx")) // private-use script code, not in map / not a Scripts table key } From b30eddbd8fec663ceab898b60c50f179312b788e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 17:01:17 +0000 Subject: [PATCH 5/5] fix(validate): locale restriction for Nd digits and script subtags - needsScriptCheck: include Unicode Nd for locale restriction - iso15924ToUnicodeScripts: Hira/Kana/Hang/Bopo aliases - Tests: ja-Hira + hiragana, en + Arabic-Indic digits, Hira/Kana tables - CHANGELOG [Unreleased] Fixed Co-authored-by: Igor Lazarev --- CHANGELOG.md | 5 +++++ validate/suspicious.go | 8 ++++++++ validate/suspicious_test.go | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5033d..23d3978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/validate/suspicious.go b/validate/suspicious.go index 0ad238d..02a31e6 100644 --- a/validate/suspicious.go +++ b/validate/suspicious.go @@ -244,6 +244,11 @@ var iso15924ToUnicodeScripts = map[string][]string{ "Hani": {"Han"}, "Jpan": {"Hiragana", "Katakana", "Han"}, "Kore": {"Hangul", "Han"}, + // BCP 47 / ISO 15924 script subtag aliases (e.g. ja-Hira, ja-Kana). + "Hira": {"Hiragana"}, + "Kana": {"Katakana"}, + "Hang": {"Hangul"}, + "Bopo": {"Bopomofo"}, } func unicodeRangeTablesForISO15924(code string) []*unicode.RangeTable { @@ -352,6 +357,9 @@ func needsScriptCheck(r rune) bool { if unicode.IsMark(r) { return true } + if unicode.Is(unicode.Nd, r) { + return true + } if unicode.Is(unicode.Nl, r) || unicode.Is(unicode.No, r) { return true } diff --git a/validate/suspicious_test.go b/validate/suspicious_test.go index ce17786..a8f0624 100644 --- a/validate/suspicious_test.go +++ b/validate/suspicious_test.go @@ -52,6 +52,11 @@ func TestNoSuspiciousCharacters_locales(t *testing.T) { NoSuspiciousCharacters("πει", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("en")), ErrSuspiciousRestriction, ) + assert.NoError(t, NoSuspiciousCharacters("かな", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("ja-Hira"))) + assert.ErrorIs(t, + NoSuspiciousCharacters("٤٥", WithSuspiciousRestriction(SuspiciousRestrictionLocales), WithSuspiciousLocales("en")), + ErrSuspiciousRestriction, + ) } func TestNoSuspiciousCharacters_whitespaceOnlyValid(t *testing.T) { @@ -138,6 +143,8 @@ func TestUnicodeRangeTablesForISO15924(t *testing.T) { assert.Nil(t, unicodeRangeTablesForISO15924("Zzzz")) require.NotEmpty(t, unicodeRangeTablesForISO15924("Latn")) require.NotEmpty(t, unicodeRangeTablesForISO15924("Jpan")) + require.NotEmpty(t, unicodeRangeTablesForISO15924("Hira")) + require.NotEmpty(t, unicodeRangeTablesForISO15924("Kana")) // Unknown ISO code: if it matches a unicode.Scripts key, return one table. if rt := unicodeRangeTablesForISO15924("Thai"); len(rt) != 1 { t.Fatalf("Thai: got %d tables", len(rt))