bitchat/bitchatTests/NicknameNormalizationTests.swift
Vidit Kulshrestha 6c8499a603
Normalize nicknames to Unicode NFC at storage and comparison boundaries (#1502)
* Replace try! regex construction with a non-trapping SafeRegex helper

MessageFormattingEngine and MessageDeduplicationService compiled eight
bundled regex literals with try!, so a bad pattern would crash the app
at startup - in the middle of the message-render path (#645).

Add SafeRegex.compile: it compiles the pattern normally, and on failure
logs through SecureLogger and returns a never-matching regex ('(?!)'),
so a broken pattern degrades that one formatting feature instead of
trapping. Pattern properties stay non-optional, so no call-site churn
across ChatMessageFormatter, MessageTextHelpers, and
ChatComposerCoordinator.

The compile-time guarantee try! provided moves into tests: each
production pattern is asserted to compile and match a known-good sample,
so a typo in a pattern now fails CI instead of crashing users.

Part of #645 (the remaining try! sites; NoiseSessionManager's
force-unwrap is addressed separately in #1456).

* Normalize nicknames to Unicode NFC at storage and comparison boundaries

A nickname containing an accent can arrive in two canonically equivalent
but bytewise different forms: precomposed (U+00E9) or decomposed
(e + U+0301), depending on the keyboard and platform that produced it.
Nicknames were stored and compared without normalization, so visually
identical names silently failed to match: mentions of your own name did
not highlight or notify, /msg and /block could not resolve the peer,
autocomplete skipped candidates, and geohash DM resolution failed (#214).

Fix by canonicalizing to NFC (String.normalizedNickname) at every
boundary where a nickname enters storage - own nickname (ChatViewModel
didSet, alongside the existing trim), verified announce ingest
(BLEPeerRegistry), geohash presence (LocationPresenceStore), and
InputValidator.validateNickname - and by normalizing both sides at
comparison sites that can still see pre-normalization data (persisted
favorites, message-content mentions): peer resolution in
UnifiedPeerService and ChatPeerIdentityCoordinator, the three mention
checks, and autocomplete prefix matching.

The wire codec (AnnouncementPacket) is deliberately untouched: announces
are signature-verified against raw bytes, so canonicalization happens at
the storage layer, never during parsing.

Fixes #214
2026-07-30 18:14:53 +01:00

54 lines
2.0 KiB
Swift

//
// NicknameNormalizationTests.swift
// bitchatTests
//
// Nicknames must compare equal regardless of how the user's keyboard
// produced them: "café" as precomposed U+00E9 and as "e" + combining
// U+0301 are canonically equivalent but bytewise different, which broke
// mention matching, DM resolution, and autocomplete (#214). Storage and
// comparison both canonicalize to NFC via String.normalizedNickname.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
struct NicknameNormalizationTests {
/// "café" with a combining acute accent (NFD form)
private let decomposed = "cafe\u{0301}"
/// "café" with precomposed é (NFC form)
private let precomposed = "caf\u{00E9}"
@Test
func canonicallyEquivalentFormsNormalizeIdentically() {
// Sanity: the raw forms really are different strings byte-wise
#expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count)
// and normalization unifies them.
#expect(decomposed.normalizedNickname == precomposed.normalizedNickname)
#expect(decomposed.normalizedNickname == precomposed)
}
@Test
func asciiNicknamesPassThroughUnchanged() {
#expect("alice_42".normalizedNickname == "alice_42")
#expect("".normalizedNickname == "")
}
@Test
func validateNicknameReturnsCanonicalForm() {
#expect(InputValidator.validateNickname(decomposed) == precomposed)
#expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed)
// Validation behavior is otherwise unchanged.
#expect(InputValidator.validateNickname(" ") == nil)
}
@Test
func collisionSuffixSplittingSurvivesNormalization() {
let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix()
#expect(base == precomposed)
#expect(suffix == "#ab12")
}
}