diff --git a/bitchat/Utils/String+DJB2.swift b/bitchat/Utils/String+DJB2.swift index df62270c..f6c73100 100644 --- a/bitchat/Utils/String+DJB2.swift +++ b/bitchat/Utils/String+DJB2.swift @@ -9,6 +9,11 @@ import Foundation extension String { + /// Returns a deterministic DJB2 hash for this string. + /// + /// DJB2 is a non-cryptographic hash used for stable peer colors and + /// other UI-related deterministic values. Do not use it for cryptographic + /// or security-sensitive purposes. func djb2() -> UInt64 { var hash: UInt64 = 5381 for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) } diff --git a/bitchat/Utils/String+Nickname.swift b/bitchat/Utils/String+Nickname.swift index 9a652667..ff0a7fcc 100644 --- a/bitchat/Utils/String+Nickname.swift +++ b/bitchat/Utils/String+Nickname.swift @@ -17,15 +17,30 @@ extension String { precomposedStringWithCanonicalMapping } - /// Split a nickname into base and a '#abcd' suffix if present + /// Splits a nickname into base and `#abcd` suffix when present. + /// + /// Mention `@` characters are removed before parsing. + /// + /// Examples: + /// - `"alice#1a2b"` returns `("alice", "#1a2b")` + /// - `"@charlie#ffff"` returns `("charlie", "#ffff")` + /// - `"bob"` returns `("bob", "")` + /// - `"test##1234"` returns `("test##1234", "")` func splitSuffix() -> (String, String) { let name = self.replacingOccurrences(of: "@", with: "") guard name.count >= 5 else { return (name, "") } let suffix = String(name.suffix(5)) - if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in - ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) - }) { - let base = String(name.dropLast(5)) + let base = String(name.dropLast(5)) + let hasValidSuffix = suffix.first == "#" && suffix.dropFirst().allSatisfy { character in + guard character.unicodeScalars.count == 1, + let scalar = character.unicodeScalars.first else { + return false + } + return (48...57).contains(scalar.value) + || (65...70).contains(scalar.value) + || (97...102).contains(scalar.value) + } + if hasValidSuffix, !base.contains("#") { return (base, suffix) } return (name, "") diff --git a/bitchatTests/Utils/ColorPeerTests.swift b/bitchatTests/Utils/ColorPeerTests.swift new file mode 100644 index 00000000..3fdb09dc --- /dev/null +++ b/bitchatTests/Utils/ColorPeerTests.swift @@ -0,0 +1,99 @@ +// +// ColorPeerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import SwiftUI +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif +@testable import bitchat + +@Suite(.serialized) +struct ColorPeerTests { + + @Test func peerColor_resolvesRepresentativeSeeds() { + let seeds = [ + "alice", + "", + "caf\u{00E9}", + "a", + String(repeating: "long-seed-", count: 12), + "nostr:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ] + + for seed in seeds { + #expect(isUsableRGBA(resolvedRGBA(for: Color(peerSeed: seed, isDark: false))), + "Light peer color should resolve for seed '\(seed)'") + #expect(isUsableRGBA(resolvedRGBA(for: Color(peerSeed: seed, isDark: true))), + "Dark peer color should resolve for seed '\(seed)'") + } + } + + @Test func peerColor_isDeterministicForSameSeedAndMode() { + let firstLight = resolvedRGBA(for: Color(peerSeed: "alice", isDark: false)) + let secondLight = resolvedRGBA(for: Color(peerSeed: "alice", isDark: false)) + let firstDark = resolvedRGBA(for: Color(peerSeed: "alice", isDark: true)) + let secondDark = resolvedRGBA(for: Color(peerSeed: "alice", isDark: true)) + + #expect(firstLight == secondLight) + #expect(firstDark == secondDark) + } + + @Test func peerColor_supportsLightAndDarkModes() { + let light = resolvedRGBA(for: Color(peerSeed: "mode-check", isDark: false)) + let dark = resolvedRGBA(for: Color(peerSeed: "mode-check", isDark: true)) + + #expect(isUsableRGBA(light)) + #expect(isUsableRGBA(dark)) + } +} + +private struct RGBA: Equatable { + let red: Double + let green: Double + let blue: Double + let alpha: Double + + var values: [Double] { + [red, green, blue, alpha] + } +} + +private func resolvedRGBA(for color: Color) -> RGBA? { +#if os(iOS) + let platformColor = UIColor(color) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard platformColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } + return RGBA(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha)) +#elseif os(macOS) + let platformColor = NSColor(color) + guard let rgbColor = platformColor.usingColorSpace(.deviceRGB) else { + return nil + } + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + rgbColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return RGBA(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha)) +#else + return nil +#endif +} + +private func isUsableRGBA(_ components: RGBA?) -> Bool { + guard let components else { return false } + return components.values.allSatisfy { (0.0...1.0).contains($0) } +} diff --git a/bitchatTests/Utils/StringUtilsTests.swift b/bitchatTests/Utils/StringUtilsTests.swift new file mode 100644 index 00000000..50bbd796 --- /dev/null +++ b/bitchatTests/Utils/StringUtilsTests.swift @@ -0,0 +1,87 @@ +// +// StringUtilsTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +@testable import bitchat + +struct StringUtilsTests { + + // MARK: - DJB2 + + @Test func djb2_isDeterministic() { + let input = "alice" + + #expect(input.djb2() == input.djb2()) + } + + @Test func djb2_isCaseSensitive() { + let hashes = Set([ + "Alice".djb2(), + "alice".djb2(), + "ALICE".djb2() + ]) + + #expect(hashes.count > 1, "DJB2 should not collapse differently cased strings to one value") + } + + @Test func djb2_unicodeContentIsDeterministic() { + let unicodeName = "caf\u{00E9}" + + #expect(unicodeName.djb2() == unicodeName.djb2()) + #expect(unicodeName.djb2() != "cafe".djb2()) + #expect("\u{1F44B}".djb2() != "\u{1F44B}!".djb2()) + } + + @Test func djb2_emptyStringUsesSeedValue() { + #expect("".djb2() == 5381) + } + + // MARK: - Nickname suffixes + + @Test func splitSuffix_parsesValidSuffix() { + let (base, suffix) = "alice#1a2b".splitSuffix() + + #expect(base == "alice") + #expect(suffix == "#1a2b") + } + + @Test func splitSuffix_parsesMentionSuffix() { + let (base, suffix) = "@charlie#ffff".splitSuffix() + + #expect(base == "charlie") + #expect(suffix == "#ffff") + } + + @Test func splitSuffix_returnsNameWhenNoSuffixExists() { + let (base, suffix) = "bob".splitSuffix() + + #expect(base == "bob") + #expect(suffix == "") + } + + @Test func splitSuffix_rejectsInvalidHex() { + let (base, suffix) = "eve#xyz1".splitSuffix() + + #expect(base == "eve#xyz1") + #expect(suffix == "") + } + + @Test func splitSuffix_rejectsShortSuffix() { + let (base, suffix) = "a#123".splitSuffix() + + #expect(base == "a#123") + #expect(suffix == "") + } + + @Test func splitSuffix_rejectsDoubleHashSuffix() { + let (base, suffix) = "test##1234".splitSuffix() + + #expect(base == "test##1234", "Double-hash nicknames must remain unsplit") + #expect(suffix == "", "Double-hash nicknames must not produce a suffix") + } +}