mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Merge 39013a4701866fdb07b4235c78ab62a1d4194848 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
3cc8d159a2
@ -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) }
|
||||
|
||||
@ -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, "")
|
||||
|
||||
99
bitchatTests/Utils/ColorPeerTests.swift
Normal file
99
bitchatTests/Utils/ColorPeerTests.swift
Normal file
@ -0,0 +1,99 @@
|
||||
//
|
||||
// ColorPeerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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) }
|
||||
}
|
||||
87
bitchatTests/Utils/StringUtilsTests.swift
Normal file
87
bitchatTests/Utils/StringUtilsTests.swift
Normal file
@ -0,0 +1,87 @@
|
||||
//
|
||||
// StringUtilsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user