diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift index 04510d6ca..a5cd6eedf 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift @@ -28,7 +28,7 @@ final class OnboardingNameViewModel { @ObservationIgnored private let flipClient: FlipClient @ObservationIgnored private let owner: KeyPair - @ObservationIgnored private let validator = DisplayNameValidator() + @ObservationIgnored private let validator = DisplayNameValidator(asciiOnly: true) /// Fires once the name is saved; the onboarding flow advances from here. @ObservationIgnored var onComplete: (@MainActor () -> Void)? diff --git a/FlipcashCore/Sources/FlipcashCore/Validation/DisplayNameValidator.swift b/FlipcashCore/Sources/FlipcashCore/Validation/DisplayNameValidator.swift index b7d4269da..4dee91768 100644 --- a/FlipcashCore/Sources/FlipcashCore/Validation/DisplayNameValidator.swift +++ b/FlipcashCore/Sources/FlipcashCore/Validation/DisplayNameValidator.swift @@ -7,14 +7,20 @@ import Foundation /// Validates a profile display name: 1–64 Unicode scalars of any script. /// -/// Returns the name trimmed of leading and trailing whitespace. +/// Returns the name trimmed of leading and trailing whitespace. With `asciiOnly`, +/// the trimmed name may also contain only `A-Z`, `a-z`, `0-9`, and U+0020. public struct DisplayNameValidator: Validator { /// PGV `max_len` from `FlipcashAPI/Core/proto/profile/v1/profile_service.proto` /// counts Unicode scalars, not grapheme clusters — one ZWJ emoji spends seven. public static let maxScalars = 64 - public init() {} + private let asciiOnly: Bool + + /// Creates a validator; `asciiOnly` restricts names to ASCII letters, digits, and spaces. + public init(asciiOnly: Bool = false) { + self.asciiOnly = asciiOnly + } public func validate(_ input: String) -> String? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) @@ -23,6 +29,10 @@ public struct DisplayNameValidator: Validator { return nil } + if asciiOnly, !trimmed.unicodeScalars.allSatisfy(Self.isASCIIAlphanumericOrSpace) { + return nil + } + return trimmed } @@ -32,4 +42,11 @@ public struct DisplayNameValidator: Validator { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) return Self.maxScalars - trimmed.unicodeScalars.count } + + private static func isASCIIAlphanumericOrSpace(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 0x20, 0x30...0x39, 0x41...0x5A, 0x61...0x7A: true + default: false + } + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/DisplayNameValidatorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/DisplayNameValidatorTests.swift index 7fd8613b2..974e628b2 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/DisplayNameValidatorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/DisplayNameValidatorTests.swift @@ -88,4 +88,37 @@ struct DisplayNameValidatorTests { func remainingIgnoresSurroundingWhitespace() { #expect(validator.remaining(in: " Ted ") == DisplayNameValidator.maxScalars - 3) } + + // MARK: - ASCII-only (onboarding) - + + private let asciiValidator = DisplayNameValidator(asciiOnly: true) + + @Test("ASCII-only accepts letters, digits, and spaces, trimmed", + arguments: [ + (input: "Taylor Smith 2", expected: "Taylor Smith 2"), + (input: " Taylor ", expected: "Taylor"), + (input: "A B", expected: "A B"), + ]) + func asciiOnlyAccepts(input: String, expected: String) { + #expect(asciiValidator.validate(input) == expected) + } + + @Test("ASCII-only rejects accents, punctuation, emoji, other whitespace, and over-limit", + arguments: [ + "Jos\u{00E9}", + "Taylor!", + "\u{1F600}", + "Tay\tlor", + "Tay\u{00A0}lor", + " ", + String(repeating: "a", count: 65), + ]) + func asciiOnlyRejects(input: String) { + #expect(asciiValidator.validate(input) == nil) + } + + @Test("Default validator still accepts accented names") + func defaultAcceptsAccents() { + #expect(validator.validate("Jos\u{00E9}") == "Jos\u{00E9}") + } }