mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-15 07:06:11 +00:00
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
This commit is contained in:
parent
e2bd13a7f2
commit
6c8499a603
@ -36,6 +36,7 @@ final class LocationPresenceStore: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let nickname = nickname.normalizedNickname
|
||||
let key = pubkeyHex.lowercased()
|
||||
if geoNicknames[key] != nil {
|
||||
geoNicknames[key] = nickname
|
||||
@ -64,7 +65,7 @@ final class LocationPresenceStore: ObservableObject {
|
||||
let lower = key.lowercased()
|
||||
guard seen.insert(lower).inserted else { continue }
|
||||
ordered.append(lower)
|
||||
normalized[lower] = value
|
||||
normalized[lower] = value.normalizedNickname
|
||||
}
|
||||
if ordered.count > geoNicknameCapacity {
|
||||
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||
|
||||
@ -55,10 +55,10 @@ final class AutocompleteService {
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased()
|
||||
|
||||
let suggestions = peers
|
||||
.filter { $0.lowercased().hasPrefix(prefix) }
|
||||
.filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
.map { "@\($0)" }
|
||||
|
||||
@ -223,7 +223,7 @@ struct BLEPeerRegistry {
|
||||
|
||||
peers[peerID] = BLEPeerInfo(
|
||||
peerID: existing?.peerID ?? peerID,
|
||||
nickname: nickname,
|
||||
nickname: nickname.normalizedNickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
// Never drop an already-pinned signing key.
|
||||
|
||||
@ -110,11 +110,12 @@ final class MessageFormattingEngine {
|
||||
)
|
||||
|
||||
// Format content
|
||||
let myNickname = context.nickname.normalizedNickname
|
||||
let contentResult = formatContent(
|
||||
message.content,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
||||
isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
|
||||
)
|
||||
result.append(contentResult)
|
||||
|
||||
|
||||
@ -236,8 +236,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> PeerID? {
|
||||
// Normalize both sides: the query may come from typed content and
|
||||
// stored names may predate NFC-at-ingest (e.g. persisted favorites).
|
||||
let target = nickname.normalizedNickname
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target {
|
||||
return peer.peerID
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,9 +39,10 @@ struct InputValidator {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Validates nickname
|
||||
/// Validates nickname and returns it in canonical (NFC) form so
|
||||
/// visually identical names always compare equal.
|
||||
static func validateNickname(_ nickname: String) -> String? {
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname
|
||||
}
|
||||
|
||||
// MARK: - Protocol Field Validation
|
||||
|
||||
@ -9,6 +9,14 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Canonical form for nickname storage and comparison (Unicode NFC).
|
||||
/// "café" typed with a combining accent and "café" typed precomposed
|
||||
/// must resolve to the same user wherever nicknames are stored or
|
||||
/// matched (mentions, DM resolution, autocomplete, geo presence).
|
||||
var normalizedNickname: String {
|
||||
precomposedStringWithCanonicalMapping
|
||||
}
|
||||
|
||||
/// Split a nickname into base and a '#abcd' suffix if present
|
||||
func splitSuffix() -> (String, String) {
|
||||
let name = self.replacingOccurrences(of: "@", with: "")
|
||||
|
||||
@ -188,7 +188,8 @@ final class ChatMessageFormatter {
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
var lastEnd = content.startIndex
|
||||
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||
let myNickname = viewModel.nickname.normalizedNickname
|
||||
let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
|
||||
|
||||
for (range, type) in allMatches {
|
||||
guard let swiftRange = Range(range, in: content) else { continue }
|
||||
|
||||
@ -501,6 +501,9 @@ final class ChatPeerIdentityCoordinator {
|
||||
|
||||
@MainActor
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||
// Queries arrive from typed commands and message content, so bring
|
||||
// them to the same canonical (NFC) form nicknames are stored in.
|
||||
let nickname = nickname.normalizedNickname
|
||||
switch context.activeChannel {
|
||||
case .location:
|
||||
if nickname.contains("#"),
|
||||
|
||||
@ -506,14 +506,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func checkForMentions(_ message: BitchatMessage) {
|
||||
var myTokens: Set<String> = [context.nickname]
|
||||
let myNickname = context.nickname.normalizedNickname
|
||||
var myTokens: Set<String> = [myNickname]
|
||||
let meshPeers = context.meshPeerNicknames()
|
||||
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
|
||||
let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") }
|
||||
if !collisions.isEmpty {
|
||||
let suffix = "#" + String(context.myPeerID.id.prefix(4))
|
||||
myTokens = [context.nickname + suffix]
|
||||
myTokens = [myNickname + suffix]
|
||||
}
|
||||
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
|
||||
let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false
|
||||
|
||||
if isMentioned && message.sender != context.nickname {
|
||||
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
||||
|
||||
@ -176,10 +176,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
|
||||
if trimmed != nickname {
|
||||
nickname = trimmed
|
||||
// Canonicalize whenever nickname is set: trim whitespace
|
||||
// (whitespace-only becomes "") and apply Unicode NFC so accented
|
||||
// names match regardless of how they were typed.
|
||||
let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname
|
||||
if cleaned != nickname {
|
||||
nickname = cleaned
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
|
||||
53
bitchatTests/NicknameNormalizationTests.swift
Normal file
53
bitchatTests/NicknameNormalizationTests.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user