mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1207ac2af5 | ||
|
|
826c7537bf | ||
|
|
76dbc98e5b | ||
|
|
7f7ea05fbc | ||
|
|
c1430eaeb9 | ||
|
|
d76472999d | ||
|
|
f9a218d68b | ||
|
|
8eb4fb60e6 | ||
|
|
ca9748ede0 | ||
|
|
946abce1b2 | ||
|
|
326ff628f7 | ||
|
|
2040e94b83 | ||
|
|
8f62dd1776 | ||
|
|
7722009f11 | ||
|
|
da0474680c | ||
|
|
0a525be57a | ||
|
|
16e9271570 | ||
|
|
747551f35a | ||
|
|
367addf138 | ||
|
|
aa35200c6f | ||
|
|
e09de446fc | ||
|
|
0714b09a89 | ||
|
|
adb2626898 | ||
|
|
20435d55e8 | ||
|
|
a0187fb430 | ||
|
|
9c55a2e1fd | ||
|
|
eb37aa8046 | ||
|
|
2edf29033f | ||
|
|
b6cb287991 | ||
|
|
c9be273750 | ||
|
|
c7280284ea | ||
|
|
24cc307a0e | ||
|
|
177642ac4d | ||
|
|
ef6309c08f | ||
|
|
7ab7fbfd1b | ||
|
|
2b5505a20d | ||
|
|
ccce384a90 | ||
|
|
e2da5e2ef9 | ||
|
|
a71b8cd545 | ||
|
|
1d4bf96f7a | ||
|
|
c55c19e738 | ||
|
|
5cafa4d5b4 | ||
|
|
567e1dbbbf | ||
|
|
9e0542df73 | ||
|
|
32a8e558ed | ||
|
|
5209a6cfcf | ||
|
|
d290fd4670 | ||
|
|
cb53a3b48e | ||
|
|
8001486a2b | ||
|
|
0d6c1a0b44 | ||
|
|
d8e8703a5f | ||
|
|
c75f32da2c | ||
|
|
dcd26c19d7 | ||
|
|
eccec2f27d | ||
|
|
de7a496af9 | ||
|
|
ee19d9c948 | ||
|
|
74414c369a | ||
|
|
7935857dae | ||
|
|
d3e32bdbee | ||
|
|
77aaa3c0d1 | ||
|
|
6183501285 | ||
|
|
89e20738c8 | ||
|
|
84c89d38d3 | ||
|
|
4ec6590b23 | ||
|
|
b619c4259d | ||
|
|
f7859f7b04 |
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
@@ -18,54 +17,24 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Configure git
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git config user.email "action@github.com"
|
||||
git config user.name "GitHub Action"
|
||||
|
||||
- name: Create update branch if changes
|
||||
id: create_branch
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
# exit early if no changes
|
||||
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# branch name with timestamp
|
||||
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Push branch
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
run: |
|
||||
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Automated update of relay data
|
||||
branch: ${{ steps.create_branch.outputs.branch }}
|
||||
base: main
|
||||
title: Automated update of relay data
|
||||
body: |
|
||||
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||
labels: automated, georelays
|
||||
|
||||
- name: No changes
|
||||
if: steps.create_branch.outputs.changed != 'true'
|
||||
run: echo "No changes to relays/online_relays_gps.csv"
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -14,11 +14,8 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
# Backup original files
|
||||
|
||||
@@ -98,8 +98,8 @@
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
|
||||
@@ -57,9 +57,11 @@ struct BitchatApp: App {
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
// Check for shared content
|
||||
@@ -187,10 +189,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -223,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +246,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
private static let targetImageBytes: Int = 60_000
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
@@ -38,32 +38,21 @@ enum ImageUtils {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
@@ -76,37 +65,14 @@ enum ImageUtils {
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
|
||||
// Draw into a new context to get a clean CGImage without metadata
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
@@ -164,7 +130,6 @@ enum ImageUtils {
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
@@ -172,9 +137,8 @@ enum ImageUtils {
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
|
||||
@@ -14,7 +14,6 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
@@ -76,14 +75,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
AVEncoderBitRateKey: 20_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
audioRecorder.record()
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
|
||||
+23926
-24248
File diff suppressed because it is too large
Load Diff
@@ -21,9 +21,8 @@ struct BitchatPacket: Codable {
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
var route: [Data]?
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
@@ -32,7 +31,6 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.route = route
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
@@ -55,7 +53,6 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.route = nil
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
@@ -84,8 +81,7 @@ struct BitchatPacket: Codable {
|
||||
payload: payload,
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version,
|
||||
route: route
|
||||
version: version
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// CommandsInfo.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
case block
|
||||
case clear
|
||||
case hug
|
||||
case message = "dm"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
return baseCommands
|
||||
}
|
||||
}
|
||||
+18
-22
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
|
||||
// Private so the callers have to go through a convenience init
|
||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||
self.prefix = prefix
|
||||
self.bare = String(bare).lowercased()
|
||||
self.bare = String(bare)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +76,6 @@ extension PeerID {
|
||||
init(hexData: Data) {
|
||||
self.init(str: hexData.hexEncodedString())
|
||||
}
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init?(hexData: Data?) {
|
||||
guard let hexData else { return nil }
|
||||
self.init(hexData: hexData)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
@@ -136,20 +130,6 @@ extension PeerID {
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerID {
|
||||
var routingData: Data? {
|
||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
||||
let short = toShort()
|
||||
return Data(hexString: short.id)
|
||||
}
|
||||
|
||||
init?(routingData: Data) {
|
||||
guard routingData.count == 8 else { return nil }
|
||||
self.init(hexData: routingData)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
extension PeerID {
|
||||
@@ -211,7 +191,9 @@ extension PeerID: Comparable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
// MARK: - String Interop Helpers
|
||||
|
||||
// MARK: CustomStringConvertible
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
@@ -219,3 +201,17 @@ extension PeerID: CustomStringConvertible {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Custom Equatable w/ String & Optionality
|
||||
|
||||
// PeerID <> String
|
||||
extension Optional where Wrapped == PeerID {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||
}
|
||||
|
||||
// String <> PeerID
|
||||
extension Optional where Wrapped == String {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import Foundation
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: PeerID // Who read it
|
||||
var readerID: String // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID.id
|
||||
var tempID = readerID
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = PeerID(hexData: readerIDData)
|
||||
guard readerID.isValid else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard PeerID(str: readerID).isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -8,14 +8,6 @@ struct RequestSyncPacket {
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
@@ -33,9 +25,6 @@ struct RequestSyncPacket {
|
||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -44,7 +33,6 @@ struct RequestSyncPacket {
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -64,16 +52,12 @@ struct RequestSyncPacket {
|
||||
case 0x03:
|
||||
if v.count > maxAcceptBytes { return nil }
|
||||
payload = v
|
||||
case 0x04:
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
case .e, .s:
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
//
|
||||
// NoiseRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//
|
||||
// NoiseSecurityConsiderations.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
|
||||
// MARK: - Security Validations
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityConstants.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityValidator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,12 @@ class NoiseSession {
|
||||
}
|
||||
}
|
||||
|
||||
func getHandshakeHash() -> Data? {
|
||||
return sessionQueue.sync {
|
||||
return handshakeHash
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
sessionQueue.sync(flags: .barrier) {
|
||||
let wasEstablished = state == .established
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error, Equatable {
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -27,6 +27,19 @@ final class NoiseSessionManager {
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
@@ -35,9 +48,14 @@ final class NoiseSessionManager {
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +68,12 @@ final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
@@ -183,6 +207,10 @@ final class NoiseSessionManager {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
//
|
||||
// SecureNoiseSession.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
@@ -17,32 +12,19 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||
|
||||
private init() {
|
||||
entries = loadLocalEntries()
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
// Load cached or bundled data synchronously
|
||||
self.entries = self.loadLocalEntries()
|
||||
// Fire-and-forget remote refresh if stale
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||
let center = Geohash.decodeCenter(geohash)
|
||||
@@ -51,148 +33,52 @@ final class GeoRelayDirectory {
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||
guard !entries.isEmpty, count > 0 else { return [] }
|
||||
|
||||
if entries.count <= count {
|
||||
return entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
}
|
||||
.map { "wss://\($0.host)" }
|
||||
}
|
||||
|
||||
var best: [(entry: Entry, distance: Double)] = []
|
||||
best.reserveCapacity(count)
|
||||
|
||||
for entry in entries {
|
||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||
if best.count < count {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||
best.insert((entry, distance), at: idx)
|
||||
best.removeLast()
|
||||
guard !entries.isEmpty else { return [] }
|
||||
let sorted = entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
}
|
||||
}
|
||||
|
||||
return best.map { "wss://\($0.entry.host)" }
|
||||
.prefix(count)
|
||||
return sorted.map { "wss://\($0.host)" }
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
func prefetchIfNeeded() {
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
await self.handleFetchFailure(.torNotReady)
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||
} catch {
|
||||
await self.handleFetchFailure(.network(error))
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(Error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let error):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
retryTask = Task { [weak self] in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await MainActor.run {
|
||||
self?.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = cacheURL() else { return }
|
||||
do {
|
||||
@@ -205,35 +91,30 @@ final class GeoRelayDirectory {
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = cacheURL(),
|
||||
if let cache = self.cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
@@ -241,6 +122,7 @@ final class GeoRelayDirectory {
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
// Skip header if present
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
@@ -261,76 +143,11 @@ final class GeoRelayDirectory {
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = NotificationCenter.default
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
observers.append(torReady)
|
||||
|
||||
#if os(iOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#elseif os(macOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
} catch { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
struct NostrEmbeddedBitChat {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
||||
payload.append(tlv)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerID(publicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData).id
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -906,16 +906,6 @@ struct NostrFilter: Encodable {
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": geohashes]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
|
||||
@@ -137,7 +137,6 @@ struct BinaryProtocol {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
@@ -161,21 +160,8 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||
let originalRoute = packet.route ?? []
|
||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
||||
if hop.count == senderIDSize { return hop }
|
||||
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
|
||||
var padded = hop
|
||||
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
|
||||
return padded
|
||||
}
|
||||
guard sanitizedRoute.count <= 255 else { return nil }
|
||||
|
||||
let hasRoute = !sanitizedRoute.isEmpty
|
||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||
|
||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||
@@ -199,7 +185,6 @@ struct BinaryProtocol {
|
||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
if hasRoute { flags |= Flags.hasRoute }
|
||||
data.append(flags)
|
||||
|
||||
if version == 2 {
|
||||
@@ -227,13 +212,6 @@ struct BinaryProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
if hasRoute {
|
||||
data.append(UInt8(sanitizedRoute.count))
|
||||
for hop in sanitizedRoute {
|
||||
data.append(hop)
|
||||
}
|
||||
}
|
||||
|
||||
if isCompressed, let originalSize = originalPayloadSize {
|
||||
if version == 2 {
|
||||
let value = UInt32(originalSize)
|
||||
@@ -343,27 +321,9 @@ struct BinaryProtocol {
|
||||
if recipientID == nil { return nil }
|
||||
}
|
||||
|
||||
var route: [Data]? = nil
|
||||
var remainingPayloadBytes = payloadLength
|
||||
|
||||
if (flags & Flags.hasRoute) != 0 {
|
||||
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
||||
remainingPayloadBytes -= 1
|
||||
if routeCount > 0 {
|
||||
var hops: [Data] = []
|
||||
for _ in 0..<Int(routeCount) {
|
||||
guard remainingPayloadBytes >= senderIDSize,
|
||||
let hop = readData(senderIDSize) else { return nil }
|
||||
remainingPayloadBytes -= senderIDSize
|
||||
hops.append(hop)
|
||||
}
|
||||
route = hops
|
||||
}
|
||||
}
|
||||
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||
let originalSize: Int
|
||||
if version == 2 {
|
||||
guard let rawSize = read32() else { return nil }
|
||||
@@ -372,12 +332,15 @@ struct BinaryProtocol {
|
||||
guard let rawSize = read16() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
}
|
||||
remainingPayloadBytes -= lengthFieldBytes
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
let compressedSize = remainingPayloadBytes
|
||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
// Guard to keep decompression bounded to sane BLE payload limits
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||
let compressedSize = payloadLength - lengthFieldBytes
|
||||
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||
|
||||
// Validate compression ratio to prevent zip bomb attacks
|
||||
// Primary protection: originalSize capped at 1MB (line 336)
|
||||
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||
guard compressedSize > 0 else { return nil }
|
||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||
guard compressionRatio <= 50_000.0 else {
|
||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||
@@ -388,9 +351,7 @@ struct BinaryProtocol {
|
||||
decompressed.count == originalSize else { return nil }
|
||||
payload = decompressed
|
||||
} else {
|
||||
guard remainingPayloadBytes >= 0,
|
||||
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||
payload = rawPayload
|
||||
}
|
||||
|
||||
@@ -410,8 +371,7 @@ struct BinaryProtocol {
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl,
|
||||
version: version,
|
||||
route: route
|
||||
version: version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -195,7 +195,7 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,57 +119,4 @@ enum Geohash {
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
|
||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||
static func neighbors(of geohash: String) -> [String] {
|
||||
guard !geohash.isEmpty else { return [] }
|
||||
|
||||
let precision = geohash.count
|
||||
let bounds = decodeBounds(geohash)
|
||||
let center = decodeCenter(geohash)
|
||||
|
||||
// Calculate cell dimensions
|
||||
let latHeight = bounds.latMax - bounds.latMin
|
||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||
|
||||
// Helper to wrap longitude around ±180
|
||||
func wrapLongitude(_ lon: Double) -> Double {
|
||||
var wrapped = lon
|
||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||
while wrapped < -180.0 { wrapped += 360.0 }
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Helper to clamp latitude to ±90
|
||||
func clampLatitude(_ lat: Double) -> Double {
|
||||
return max(-90.0, min(90.0, lat))
|
||||
}
|
||||
|
||||
// Calculate 8 neighbor centers
|
||||
let neighbors: [(lat: Double, lon: Double)] = [
|
||||
(center.lat + latHeight, center.lon), // N
|
||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||
(center.lat, center.lon + lonWidth), // E
|
||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||
(center.lat - latHeight, center.lon), // S
|
||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||
(center.lat, center.lon - lonWidth), // W
|
||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||
]
|
||||
|
||||
// Encode each neighbor, handling boundary conditions
|
||||
return neighbors.compactMap { neighbor in
|
||||
let lat = clampLatitude(neighbor.lat)
|
||||
let lon = wrapLongitude(neighbor.lon)
|
||||
|
||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,18 +116,4 @@ enum ChannelID: Equatable, Codable {
|
||||
case .location(let ch): return ch.geohash
|
||||
}
|
||||
}
|
||||
|
||||
var isMesh: Bool {
|
||||
switch self {
|
||||
case .mesh: true
|
||||
case .location: false
|
||||
}
|
||||
}
|
||||
|
||||
var isLocation: Bool {
|
||||
switch self {
|
||||
case .mesh: false
|
||||
case .location: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,11 @@ struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -37,16 +35,6 @@ struct AnnouncementPacket {
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
data.append(signingPublicKey)
|
||||
|
||||
// TLV for direct neighbors (optional)
|
||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
||||
data.append(TLVType.directNeighbors.rawValue)
|
||||
data.append(UInt8(neighborsData.count))
|
||||
data.append(neighborsData)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -56,7 +44,6 @@ struct AnnouncementPacket {
|
||||
var nickname: String?
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -76,17 +63,6 @@ struct AnnouncementPacket {
|
||||
noisePublicKey = Data(value)
|
||||
case .signingPublicKey:
|
||||
signingPublicKey = Data(value)
|
||||
case .directNeighbors:
|
||||
if length > 0 && length % 8 == 0 {
|
||||
var neighbors = [Data]()
|
||||
let count = length / 8
|
||||
for i in 0..<count {
|
||||
let start = value.startIndex + i * 8
|
||||
let end = start + 8
|
||||
neighbors.append(Data(value[start..<end]))
|
||||
}
|
||||
directNeighbors = neighbors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -98,8 +74,7 @@ struct AnnouncementPacket {
|
||||
return AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
signingPublicKey: signingPublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
//
|
||||
// MimeType.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Extensions for missing UTTypes
|
||||
|
||||
extension UTType {
|
||||
static let webP = UTType(importedAs: "image/webp")
|
||||
static let aac = UTType(importedAs: "audio/aac")
|
||||
static let m4a = UTType(importedAs: "audio/m4a")
|
||||
static let ogg = UTType(importedAs: "audio/ogg")
|
||||
}
|
||||
|
||||
// MARK: - MimeType Enum
|
||||
|
||||
enum MimeType: CaseIterable, Hashable {
|
||||
case jpeg
|
||||
case jpg
|
||||
case png
|
||||
case gif
|
||||
case webp
|
||||
case mp4Audio
|
||||
case m4a
|
||||
case aac
|
||||
case mpeg
|
||||
case mp3
|
||||
case wav
|
||||
case xWav
|
||||
case ogg
|
||||
case pdf
|
||||
case octetStream
|
||||
|
||||
var utType: UTType {
|
||||
switch self {
|
||||
case .jpeg, .jpg: .jpeg
|
||||
case .png: .png
|
||||
case .gif: .gif
|
||||
case .webp: .webP
|
||||
case .aac: .aac
|
||||
case .m4a: .m4a
|
||||
case .mp4Audio: .mpeg4Audio
|
||||
case .mp3, .mpeg: .mp3
|
||||
case .wav, .xWav: .wav
|
||||
case .ogg: .ogg
|
||||
case .pdf: .pdf
|
||||
case .octetStream: .data
|
||||
}
|
||||
}
|
||||
|
||||
var category: Category {
|
||||
switch self {
|
||||
case .jpeg, .jpg, .png, .gif, .webp:
|
||||
return .image
|
||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
||||
return .audio
|
||||
case .pdf, .octetStream:
|
||||
return .file
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var mimeString: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "image/jpeg"
|
||||
case .png: "image/png"
|
||||
case .gif: "image/gif"
|
||||
case .webp: "image/webp"
|
||||
case .mp4Audio: "audio/mp4"
|
||||
case .m4a: "audio/m4a"
|
||||
case .aac: "audio/aac"
|
||||
case .mpeg: "audio/mpeg"
|
||||
case .mp3: "audio/mp3"
|
||||
case .wav: "audio/wav"
|
||||
case .xWav: "audio/x-wav"
|
||||
case .ogg: "audio/ogg"
|
||||
case .pdf: "application/pdf"
|
||||
case .octetStream: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
var defaultExtension: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "jpg"
|
||||
case .png: "png"
|
||||
case .webp: "webp"
|
||||
case .gif: "gif"
|
||||
case .mp4Audio, .m4a, .aac: "m4a"
|
||||
case .mpeg, .mp3: "mp3"
|
||||
case .wav, .xWav: "wav"
|
||||
case .ogg: "ogg"
|
||||
case .pdf: "pdf"
|
||||
case .octetStream: "bin"
|
||||
}
|
||||
}
|
||||
|
||||
static var allowed: Set<MimeType> = [
|
||||
.jpeg, .jpg, .png, .gif, .webp,
|
||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
||||
.wav, .xWav, .ogg,
|
||||
.pdf, .octetStream
|
||||
]
|
||||
|
||||
var isAllowed: Bool {
|
||||
Self.allowed.contains(self)
|
||||
}
|
||||
|
||||
// MARK: - Byte signature validation
|
||||
func matches(data: Data) -> Bool {
|
||||
guard !data.isEmpty else { return false }
|
||||
|
||||
// Generic type → skip validation
|
||||
if self == .octetStream { return true }
|
||||
|
||||
switch self {
|
||||
case .jpeg, .jpg:
|
||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
||||
|
||||
case .png:
|
||||
return data.count >= 8 &&
|
||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
||||
|
||||
case .gif:
|
||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
||||
|
||||
case .webp:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
||||
|
||||
case .m4a, .mp4Audio, .aac:
|
||||
// AVAudioRecorder output varies by platform - be lenient
|
||||
// Security: size already capped + sandboxed execution
|
||||
return data.count > 100
|
||||
|
||||
case .mpeg, .mp3:
|
||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
||||
return true // ID3 header
|
||||
}
|
||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
||||
|
||||
case .wav, .xWav:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
||||
|
||||
case .ogg:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
||||
|
||||
case .pdf:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
init?(_ mimeString: String?) {
|
||||
guard let mimeString else { return nil }
|
||||
|
||||
let normalized = mimeString.lowercased()
|
||||
|
||||
// Direct match with our canonical list
|
||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
||||
if let type = UTType(mimeType: normalized),
|
||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
case audio, image, file
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,11 +65,14 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
//
|
||||
case "/help", "/h":
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
@@ -145,9 +148,9 @@ final class CommandProcessor {
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
// Also add a local system message so the sender sees a natural-language confirmation
|
||||
@@ -211,7 +214,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -255,7 +258,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
@@ -282,7 +285,7 @@ final class CommandProcessor {
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
let noisePublicKey = Data(hexString: peerID) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
@@ -308,4 +311,19 @@ final class CommandProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHelp() -> CommandResult {
|
||||
let helpText = """
|
||||
commands:
|
||||
/msg @name - start private chat
|
||||
/who - list who's online
|
||||
/clear - clear messages
|
||||
/hug @name - send a hug
|
||||
/slap @name - slap with a trout
|
||||
/fav @name - add to favorites
|
||||
/unfav @name - remove from favorites
|
||||
/block @name - block
|
||||
/unblock @name - unblock
|
||||
"""
|
||||
return .success(message: helpText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,4 +216,15 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
func _resetForTesting() {
|
||||
bookmarks.removeAll()
|
||||
membership.removeAll()
|
||||
bookmarkNames.removeAll()
|
||||
persist()
|
||||
persistNames()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -27,6 +27,34 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
private let service = BitchatApp.bundleID
|
||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||
|
||||
private func isSandboxed() -> Bool {
|
||||
#if os(macOS)
|
||||
// More robust sandbox detection using multiple methods
|
||||
|
||||
// Method 1: Check environment variable (can be spoofed)
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
||||
|
||||
// Method 2: Check if we can access a path outside sandbox
|
||||
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
|
||||
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
|
||||
if canWriteOutsideSandbox {
|
||||
try? FileManager.default.removeItem(at: testPath)
|
||||
}
|
||||
|
||||
// Method 3: Check container path
|
||||
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
|
||||
let hasContainerPath = containerPath.contains("/Containers/")
|
||||
|
||||
// If any method indicates sandbox, we consider it sandboxed
|
||||
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
|
||||
#else
|
||||
// iOS is always sandboxed
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Identity Keys
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
|
||||
@@ -64,9 +64,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
case .notDetermined, .restricted, .denied:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct LocationNotesCounterDependencies {
|
||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||
|
||||
var relayLookup: RelayLookup
|
||||
var subscribe: Subscribe
|
||||
var unsubscribe: Unsubscribe
|
||||
|
||||
static let live = LocationNotesCounterDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
},
|
||||
subscribe: { filter, id, relays, handler, onEOSE in
|
||||
NostrRelayManager.shared.subscribe(
|
||||
filter: filter,
|
||||
id: id,
|
||||
relayUrls: relays,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||
@MainActor
|
||||
final class LocationNotesCounter: ObservableObject {
|
||||
static let shared = LocationNotesCounter()
|
||||
|
||||
@Published private(set) var geohash: String? = nil
|
||||
@Published private(set) var count: Int? = 0
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var relayAvailable: Bool = true
|
||||
|
||||
private var subscriptionID: String? = nil
|
||||
private var noteIDs = Set<String>()
|
||||
private let dependencies: LocationNotesCounterDependencies
|
||||
|
||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
init(testDependencies: LocationNotesCounterDependencies) {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
geohash = norm
|
||||
noteIDs.removeAll()
|
||||
initialLoadComplete = false
|
||||
relayAvailable = true
|
||||
|
||||
// Subscribe only to the building geohash (precision 8)
|
||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
relayAvailable = false
|
||||
initialLoadComplete = true
|
||||
count = 0
|
||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||
if !self.noteIDs.contains(event.id) {
|
||||
self.noteIDs.insert(event.id)
|
||||
self.count = self.noteIDs.count
|
||||
}
|
||||
}, { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
geohash = nil
|
||||
count = 0
|
||||
noteIDs.removeAll()
|
||||
relayAvailable = true
|
||||
}
|
||||
}
|
||||
@@ -163,22 +163,14 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
subscriptionID = subID
|
||||
initialLoadComplete = false
|
||||
|
||||
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||
let neighbors = Geohash.neighbors(of: geohash)
|
||||
let allGeohashes = [geohash] + neighbors
|
||||
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||
|
||||
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag - accept any of our 9 geohashes
|
||||
guard event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||
}) else { return }
|
||||
// Ensure matching tag
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Tracks observed mesh topology and computes hop-by-hop routes.
|
||||
final class MeshTopologyTracker {
|
||||
private typealias RoutingID = Data
|
||||
|
||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
||||
private let hopSize = 8
|
||||
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
||||
|
||||
func reset() {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.adjacency.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func recordDirectLink(between a: Data?, and b: Data?) {
|
||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
var setA = self.adjacency[left] ?? []
|
||||
setA.insert(right)
|
||||
self.adjacency[left] = setA
|
||||
|
||||
var setB = self.adjacency[right] ?? []
|
||||
setB.insert(left)
|
||||
self.adjacency[right] = setB
|
||||
}
|
||||
}
|
||||
|
||||
func removeDirectLink(between a: Data?, and b: Data?) {
|
||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
if var setA = self.adjacency[left] {
|
||||
setA.remove(right)
|
||||
self.adjacency[left] = setA.isEmpty ? nil : setA
|
||||
}
|
||||
if var setB = self.adjacency[right] {
|
||||
setB.remove(left)
|
||||
self.adjacency[right] = setB.isEmpty ? nil : setB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePeer(_ data: Data?) {
|
||||
guard let peer = sanitize(data) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
||||
for neighbor in neighbors {
|
||||
if var set = self.adjacency[neighbor] {
|
||||
set.remove(peer)
|
||||
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordRoute(_ hops: [Data]) {
|
||||
let sanitized = hops.compactMap { sanitize($0) }
|
||||
guard sanitized.count >= 2 else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
for idx in 0..<(sanitized.count - 1) {
|
||||
let left = sanitized[idx]
|
||||
let right = sanitized[idx + 1]
|
||||
guard left != right else { continue }
|
||||
|
||||
var setA = self.adjacency[left] ?? []
|
||||
setA.insert(right)
|
||||
self.adjacency[left] = setA
|
||||
|
||||
var setB = self.adjacency[right] ?? []
|
||||
setB.insert(left)
|
||||
self.adjacency[right] = setB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||
if source == target { return [source] }
|
||||
|
||||
let graph = queue.sync { adjacency }
|
||||
guard graph[source] != nil, graph[target] != nil else { return nil }
|
||||
|
||||
var visited: Set<RoutingID> = [source]
|
||||
var queuePaths: [[RoutingID]] = [[source]]
|
||||
var index = 0
|
||||
|
||||
while index < queuePaths.count {
|
||||
let path = queuePaths[index]
|
||||
index += 1
|
||||
guard path.count <= maxHops else { continue }
|
||||
guard let last = path.last, let neighbors = graph[last] else { continue }
|
||||
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
var nextPath = path
|
||||
nextPath.append(neighbor)
|
||||
if neighbor == target { return nextPath }
|
||||
if nextPath.count <= maxHops {
|
||||
queuePaths.append(nextPath)
|
||||
}
|
||||
visited.insert(neighbor)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sanitize(_ data: Data?) -> Data? {
|
||||
guard var value = data, !value.isEmpty else { return nil }
|
||||
if value.count > hopSize {
|
||||
value = Data(value.prefix(hopSize))
|
||||
} else if value.count < hopSize {
|
||||
value.append(Data(repeating: 0, count: hopSize - value.count))
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
|
||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy support - setting this will add to the handlers array
|
||||
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
||||
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||
get { nil } // Always return nil for backward compatibility
|
||||
set {
|
||||
if let handler = newValue {
|
||||
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
handler(peerID.id, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class NostrTransport: Transport {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -114,7 +114,7 @@ final class NostrTransport: Transport {
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -139,7 +139,7 @@ final class NostrTransport: Transport {
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -161,7 +161,7 @@ extension NostrTransport {
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
@@ -171,7 +171,7 @@ extension NostrTransport {
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
@@ -184,7 +184,7 @@ extension NostrTransport {
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -223,7 +223,7 @@ extension NostrTransport {
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { scheduleNextReadAck(); return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
|
||||
@@ -29,30 +29,28 @@ final class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
func sendLocalNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
identifier: String,
|
||||
userInfo: [String: Any]? = nil,
|
||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.interruptionLevel = interruptionLevel
|
||||
|
||||
if let userInfo = userInfo {
|
||||
content.userInfo = userInfo
|
||||
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
||||
// For now, skip app state check entirely to avoid thread issues
|
||||
// The NotificationDelegate will handle foreground presentation
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
if let userInfo = userInfo {
|
||||
content.userInfo = userInfo
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
@@ -63,11 +61,11 @@ final class NotificationService {
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||
}
|
||||
|
||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||
let title = "🔒 DM from \(sender)"
|
||||
let body = message
|
||||
let identifier = "private-\(UUID().uuidString)"
|
||||
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
@@ -85,12 +83,25 @@ final class NotificationService {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||
|
||||
sendLocalNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
identifier: identifier,
|
||||
interruptionLevel: .timeSensitive
|
||||
)
|
||||
|
||||
// For network notifications, we want to show them even in foreground
|
||||
// No app state check - let the notification delegate handle presentation
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.interruptionLevel = .timeSensitive // Make it more prominent
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,10 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
private var buffer = Data()
|
||||
private var pendingFrameStartedAt: DispatchTime?
|
||||
private var pendingFrameExpectedLength: Int = 0
|
||||
|
||||
private mutating func resetState() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
}
|
||||
|
||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||
guard !chunk.isEmpty else { return ([], [], false) }
|
||||
@@ -27,107 +18,64 @@ struct NotificationStreamAssembler {
|
||||
|
||||
var frames: [Data] = []
|
||||
var dropped: [UInt8] = []
|
||||
var didReset = false
|
||||
let now = DispatchTime.now()
|
||||
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||
var reset = false
|
||||
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||
|
||||
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||
resetState()
|
||||
return ([], [], true)
|
||||
}
|
||||
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||
|
||||
while buffer.count >= minimumFramePrefix {
|
||||
guard let version = buffer.first else { break }
|
||||
guard version == 1 || version == 2 else {
|
||||
while buffer.count >= minFramePrefix {
|
||||
guard let first = buffer.first else { break }
|
||||
if first != 1 {
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
continue
|
||||
}
|
||||
|
||||
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
continue
|
||||
}
|
||||
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
||||
guard buffer.count >= framePrefix else { break }
|
||||
guard buffer.count >= minHeaderBytes else { break }
|
||||
|
||||
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||
guard flagsIndex < buffer.endIndex else { break }
|
||||
let flags = buffer[flagsIndex]
|
||||
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||
guard headerBytes.count == minFramePrefix else { break }
|
||||
|
||||
let flags = headerBytes[11]
|
||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||
|
||||
let lengthOffset = 12
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
let lengthIndex = buffer.startIndex + lengthOffset
|
||||
payloadLength =
|
||||
(Int(buffer[lengthIndex]) << 24) |
|
||||
(Int(buffer[lengthIndex + 1]) << 16) |
|
||||
(Int(buffer[lengthIndex + 2]) << 8) |
|
||||
Int(buffer[lengthIndex + 3])
|
||||
} else {
|
||||
let lengthIndex = buffer.startIndex + lengthOffset
|
||||
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
|
||||
}
|
||||
|
||||
var frameLength = framePrefix + payloadLength
|
||||
var frameLength = minFramePrefix + payloadLen
|
||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||
if isCompressed {
|
||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||
if payloadLength < rawLengthFieldBytes {
|
||||
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
buffer.removeAll()
|
||||
reset = true
|
||||
break
|
||||
}
|
||||
|
||||
if buffer.count < frameLength {
|
||||
let remaining = frameLength - buffer.count
|
||||
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||
pendingFrameStartedAt = now
|
||||
pendingFrameExpectedLength = frameLength
|
||||
} else if let started = pendingFrameStartedAt {
|
||||
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
||||
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
||||
if elapsed >= threshold {
|
||||
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
} else {
|
||||
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
||||
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||
if dropCount > 0 {
|
||||
buffer.removeFirst(dropCount)
|
||||
dropped.append(1) // treat as dropped partial start
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
|
||||
let frame = Data(buffer.prefix(frameLength))
|
||||
frames.append(frame)
|
||||
buffer.removeFirst(frameLength)
|
||||
}
|
||||
|
||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||
resetState()
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
return (frames, dropped, didReset)
|
||||
return (frames, dropped, reset)
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||
readerID: meshService?.myPeerID.id ?? "",
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ struct RelayController {
|
||||
senderIsSelf: Bool,
|
||||
isEncrypted: Bool,
|
||||
isDirectedEncrypted: Bool,
|
||||
isFragment: Bool,
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
@@ -37,16 +36,6 @@ struct RelayController {
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
if isFragment {
|
||||
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
||||
guard ttlLimit > 1 else {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
||||
}
|
||||
let newTTL = ttlLimit &- 1
|
||||
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Announces get a bit more headroom
|
||||
|
||||
@@ -45,7 +45,6 @@ protocol Transport: AnyObject {
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
@@ -66,10 +65,6 @@ extension Transport {
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
sendMessage(content, mentions: mentions)
|
||||
}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -8,10 +8,6 @@ enum TransportConfig {
|
||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
||||
|
||||
// UI / Storage Caps
|
||||
static let privateChatCap: Int = 1337
|
||||
@@ -70,7 +66,6 @@ enum TransportConfig {
|
||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
||||
|
||||
// BLE maintenance & thresholds
|
||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||
@@ -150,9 +145,6 @@ enum TransportConfig {
|
||||
|
||||
// Geo relay directory
|
||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
||||
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
||||
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
||||
|
||||
// BLE operational delays
|
||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||
|
||||
@@ -235,10 +235,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> PeerID? {
|
||||
func getPeerID(for nickname: String) -> String? {
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
return peer.peerID
|
||||
return peer.peerID.id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -347,7 +347,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// MARK: - Compatibility Methods (for easy migration)
|
||||
|
||||
var allPeers: [BitchatPeer] { peers }
|
||||
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
||||
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||
}
|
||||
|
||||
@@ -8,55 +8,6 @@ final class GossipSyncManager {
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
}
|
||||
|
||||
private struct PacketStore {
|
||||
private(set) var packets: [String: BitchatPacket] = [:]
|
||||
private(set) var order: [String] = []
|
||||
|
||||
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
|
||||
guard capacity > 0 else { return }
|
||||
if packets[idHex] != nil {
|
||||
packets[idHex] = packet
|
||||
return
|
||||
}
|
||||
packets[idHex] = packet
|
||||
order.append(idHex)
|
||||
while order.count > capacity {
|
||||
let victim = order.removeFirst()
|
||||
packets.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
|
||||
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
|
||||
order.compactMap { key in
|
||||
guard let packet = packets[key], isFresh(packet) else { return nil }
|
||||
return packet
|
||||
}
|
||||
}
|
||||
|
||||
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
|
||||
var nextOrder: [String] = []
|
||||
for key in order {
|
||||
guard let packet = packets[key] else { continue }
|
||||
if shouldRemove(packet) {
|
||||
packets.removeValue(forKey: key)
|
||||
} else {
|
||||
nextOrder.append(key)
|
||||
}
|
||||
}
|
||||
order = nextOrder
|
||||
}
|
||||
|
||||
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
|
||||
remove { !isFresh($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SyncSchedule {
|
||||
let types: SyncTypeFlags
|
||||
let interval: TimeInterval
|
||||
var lastSent: Date
|
||||
}
|
||||
|
||||
struct Config {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
@@ -65,43 +16,25 @@ final class GossipSyncManager {
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
var fragmentCapacity: Int = 600
|
||||
var fileTransferCapacity: Int = 200
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
}
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
||||
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
||||
private var messageOrder: [String] = []
|
||||
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
||||
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -122,18 +55,7 @@ final class GossipSyncManager {
|
||||
|
||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
||||
}
|
||||
}
|
||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
||||
}
|
||||
}
|
||||
self?.sendRequestSync(to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,45 +87,47 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let r = packet.recipientID else { return true }
|
||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
||||
let isAnnounce = (mt == .announce)
|
||||
guard isBroadcastMessage || isAnnounce else { return }
|
||||
|
||||
switch messageType {
|
||||
case .announce:
|
||||
guard isPacketFresh(packet) else { return }
|
||||
// Reject expired packets to prevent ghost peers and old messages
|
||||
guard isPacketFresh(packet) else { return }
|
||||
|
||||
if isAnnounce {
|
||||
guard isAnnouncementFresh(packet) else {
|
||||
let sender = PeerID(hexData: packet.senderID)
|
||||
removeState(for: sender)
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
removeState(forNormalizedPeerID: sender)
|
||||
return
|
||||
}
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
let sender = PeerID(hexData: packet.senderID)
|
||||
}
|
||||
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
|
||||
if isBroadcastMessage {
|
||||
if messages[idHex] == nil {
|
||||
messages[idHex] = packet
|
||||
messageOrder.append(idHex)
|
||||
// Enforce capacity
|
||||
let cap = max(1, config.seenCapacity)
|
||||
while messageOrder.count > cap {
|
||||
let victim = messageOrder.removeFirst()
|
||||
messages.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
} else if isAnnounce {
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||
case .message:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
case .fragment:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
|
||||
case .fileTransfer:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
private func sendRequestSync() {
|
||||
let payload = buildGcsPayload()
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
@@ -217,8 +141,8 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
private func sendRequestSync(to peerID: PeerID) {
|
||||
let payload = buildGcsPayload()
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
@@ -246,7 +170,6 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
// Decode GCS into sorted set and prepare membership checker
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -254,100 +177,60 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.message) {
|
||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in toSendMsgs {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fragment) {
|
||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in frags {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fileTransfer) {
|
||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in files {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
// 2) Broadcast messages: send all missing (and not expired)
|
||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||
for pkt in toSendMsgs {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||
private func buildGcsPayload() -> Data {
|
||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||
var candidates: [BitchatPacket] = []
|
||||
if types.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
if isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
if types.contains(.message) {
|
||||
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
||||
for id in messageOrder {
|
||||
if let p = messages[id], isPacketFresh(p) {
|
||||
candidates.append(p)
|
||||
}
|
||||
}
|
||||
if types.contains(.fragment) {
|
||||
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Sort by timestamp desc
|
||||
candidates.sort { $0.timestamp > $1.timestamp }
|
||||
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||
let cap: Int
|
||||
if types == .fragment {
|
||||
cap = max(1, config.fragmentCapacity)
|
||||
} else if types == .fileTransfer {
|
||||
cap = max(1, config.fileTransferCapacity)
|
||||
} else {
|
||||
cap = max(1, config.seenCapacity)
|
||||
}
|
||||
let cap = max(1, config.seenCapacity)
|
||||
let takeN = min(candidates.count, min(nMax, cap))
|
||||
if takeN <= 0 {
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
||||
return req.encode()
|
||||
}
|
||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
@@ -358,21 +241,20 @@ final class GossipSyncManager {
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
messages.removeExpired(isFresh: isPacketFresh)
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
// Remove expired messages
|
||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||
isPacketFresh(pkt) ? nil : id
|
||||
}
|
||||
for id in expiredMessageIds {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendRequestSync(for: syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
sendRequestSync()
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||
@@ -388,27 +270,40 @@ final class GossipSyncManager {
|
||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMs >= timeoutMs else { return }
|
||||
let cutoff = nowMs - timeoutMs
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||
pair.packet.timestamp < cutoff ? peerID : nil
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||
}
|
||||
guard !stalePeerIDs.isEmpty else { return }
|
||||
for peerKey in stalePeerIDs {
|
||||
removeState(for: peerKey)
|
||||
removeState(forNormalizedPeerID: peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.removeState(for: peerID)
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeState(for peerID: PeerID) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.id.lowercased()
|
||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||
}
|
||||
|
||||
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
// Remove messages from this peer
|
||||
// Collect IDs to remove first to avoid concurrent modification
|
||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
|
||||
}
|
||||
|
||||
// Remove messages and update messageOrder
|
||||
for id in messageIdsToRemove {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,13 +317,13 @@ extension GossipSyncManager {
|
||||
|
||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
latestAnnouncementByPeer[peerID] != nil
|
||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||
}
|
||||
}
|
||||
|
||||
func _messageCount(for peerID: PeerID) -> Int {
|
||||
queue.sync {
|
||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||
/// Matches the Android mapping (bit index -> message type).
|
||||
struct SyncTypeFlags: OptionSet {
|
||||
let rawValue: UInt64
|
||||
|
||||
init(rawValue: UInt64) {
|
||||
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
||||
}
|
||||
|
||||
private static func bitIndex(for type: MessageType) -> Int? {
|
||||
switch type {
|
||||
case .announce: return 0
|
||||
case .message: return 1
|
||||
case .leave: return 2
|
||||
case .noiseHandshake: return 3
|
||||
case .noiseEncrypted: return 4
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
}
|
||||
}
|
||||
|
||||
private static func type(forBit index: Int) -> MessageType? {
|
||||
switch index {
|
||||
case 0: return .announce
|
||||
case 1: return .message
|
||||
case 2: return .leave
|
||||
case 3: return .noiseHandshake
|
||||
case 4: return .noiseEncrypted
|
||||
case 5: return .fragment
|
||||
case 6: return .requestSync
|
||||
case 7: return .fileTransfer
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static let announce = SyncTypeFlags(messageTypes: [.announce])
|
||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
|
||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||
|
||||
init(messageTypes: [MessageType]) {
|
||||
var raw: UInt64 = 0
|
||||
for type in messageTypes {
|
||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
|
||||
raw |= (1 << UInt64(bit))
|
||||
}
|
||||
self.init(rawValue: raw)
|
||||
}
|
||||
|
||||
func contains(_ type: MessageType) -> Bool {
|
||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
|
||||
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
|
||||
}
|
||||
|
||||
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
||||
SyncTypeFlags(rawValue: rawValue | other.rawValue)
|
||||
}
|
||||
|
||||
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
||||
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
||||
}
|
||||
|
||||
func toMessageTypes() -> [MessageType] {
|
||||
guard rawValue != 0 else { return [] }
|
||||
var types: [MessageType] = []
|
||||
for bit in 0..<64 {
|
||||
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
|
||||
if let type = SyncTypeFlags.type(forBit: bit) {
|
||||
types.append(type)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func toData() -> Data? {
|
||||
guard rawValue != 0 else { return nil }
|
||||
var value = rawValue
|
||||
var bytes: [UInt8] = []
|
||||
while value > 0 && bytes.count < 8 {
|
||||
bytes.append(UInt8(value & 0xFF))
|
||||
value >>= 8
|
||||
}
|
||||
while let last = bytes.last, last == 0 {
|
||||
bytes.removeLast()
|
||||
}
|
||||
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> SyncTypeFlags? {
|
||||
guard (1...8).contains(data.count) else { return nil }
|
||||
var raw: UInt64 = 0
|
||||
for (index, byte) in data.enumerated() {
|
||||
raw |= UInt64(byte) << UInt64(index * 8)
|
||||
}
|
||||
return SyncTypeFlags(rawValue: raw)
|
||||
}
|
||||
}
|
||||
@@ -61,13 +61,15 @@ struct CompressionUtil {
|
||||
// 1. Data is too small
|
||||
// 2. Data appears to be already compressed (high entropy)
|
||||
guard data.count >= compressionThreshold else { return false }
|
||||
|
||||
// Quick uniqueness check — a high diversity of bytes usually means the
|
||||
// payload is already compressed. We only need to know how many unique
|
||||
// values exist rather than keeping full frequency counts.
|
||||
let uniqueByteCount = Set(data).count
|
||||
let sampleSize = min(data.count, 256)
|
||||
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
||||
|
||||
// Simple entropy check - count unique bytes
|
||||
var byteFrequency = [UInt8: Int]()
|
||||
for byte in data {
|
||||
byteFrequency[byte, default: 0] += 1
|
||||
}
|
||||
|
||||
// If we have very high byte diversity, data is likely already compressed
|
||||
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
|
||||
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,9 @@ enum FileTransferLimits {
|
||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Voice notes stay small for low-latency relays.
|
||||
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||
static let maxFramedFileBytes: Int = {
|
||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||
+ BinaryProtocol.senderIDSize
|
||||
+ BinaryProtocol.recipientIDSize
|
||||
+ BinaryProtocol.signatureSize
|
||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||
}()
|
||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
|
||||
static func isValidPayload(_ size: Int) -> Bool {
|
||||
size <= maxPayloadBytes
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Comprehensive input validation for BitChat protocol
|
||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||
@@ -17,28 +16,29 @@ struct InputValidator {
|
||||
// MARK: - String Content Validation
|
||||
|
||||
/// Validates and sanitizes user-provided strings used in UI
|
||||
///
|
||||
/// Rejects strings containing control characters to prevent potential security issues
|
||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
// Check empty
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
// Trim whitespace
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
// Check length
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Reject control characters outright instead of rewriting the string.
|
||||
// This prevents injection attacks and ensures consistent UI rendering.
|
||||
// Remove control characters
|
||||
let controlChars = CharacterSet.controlCharacters
|
||||
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
||||
// Log rejection for monitoring, without exposing actual content for privacy
|
||||
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
||||
SecureLogger.debug(
|
||||
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
||||
category: .security
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return trimmed
|
||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||
|
||||
// Ensure valid UTF-8 (should already be, but double-check)
|
||||
guard cleaned.data(using: .utf8) != nil else { return nil }
|
||||
|
||||
// Prevent zero-width characters and other invisible unicode
|
||||
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
||||
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
||||
|
||||
return visible.isEmpty ? nil : visible
|
||||
}
|
||||
|
||||
/// Validates nickname
|
||||
|
||||
+1283
-644
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
//
|
||||
// GeoChannelCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// Centralizes Combine wiring for location channel selection and sampling.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Tor
|
||||
|
||||
@MainActor
|
||||
final class GeoChannelCoordinator {
|
||||
private let locationManager: LocationChannelManager
|
||||
private let bookmarksStore: GeohashBookmarksStore
|
||||
private let torManager: TorManager
|
||||
|
||||
private let onChannelSwitch: (ChannelID) -> Void
|
||||
private let beginSampling: ([String]) -> Void
|
||||
private let endSampling: () -> Void
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var regionalGeohashes: [String] = []
|
||||
private var bookmarkedGeohashes: [String] = []
|
||||
|
||||
init(
|
||||
locationManager: LocationChannelManager? = nil,
|
||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||
torManager: TorManager? = nil,
|
||||
onChannelSwitch: @escaping (ChannelID) -> Void,
|
||||
beginSampling: @escaping ([String]) -> Void,
|
||||
endSampling: @escaping () -> Void
|
||||
) {
|
||||
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
||||
self.torManager = torManager ?? Self.defaultTorManager()
|
||||
self.onChannelSwitch = onChannelSwitch
|
||||
self.beginSampling = beginSampling
|
||||
self.endSampling = endSampling
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
|
||||
bookmarkedGeohashes = bookmarksStore.bookmarks
|
||||
|
||||
locationManager.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.onChannelSwitch(channel)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationManager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
guard let self else { return }
|
||||
self.regionalGeohashes = channels.map { $0.geohash }
|
||||
self.updateSampling()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
bookmarksStore.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bookmarks in
|
||||
guard let self else { return }
|
||||
self.bookmarkedGeohashes = bookmarks
|
||||
self.updateSampling()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationManager.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self, state == .authorized else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task { @MainActor in
|
||||
self.onChannelSwitch(self.locationManager.selectedChannel)
|
||||
}
|
||||
updateSampling()
|
||||
}
|
||||
|
||||
private func updateSampling() {
|
||||
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
||||
Task { @MainActor in
|
||||
guard !union.isEmpty else {
|
||||
endSampling()
|
||||
return
|
||||
}
|
||||
if torManager.isForeground() {
|
||||
beginSampling(union)
|
||||
} else {
|
||||
endSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshSampling() {
|
||||
updateSampling()
|
||||
}
|
||||
private static func defaultLocationManager() -> LocationChannelManager {
|
||||
LocationChannelManager.shared
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func defaultTorManager() -> TorManager {
|
||||
TorManager.shared
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//
|
||||
// MessageRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles per-sender and per-content token buckets for public message intake.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MessageRateLimiter {
|
||||
private struct TokenBucket {
|
||||
var capacity: Double
|
||||
var tokens: Double
|
||||
var refillPerSec: Double
|
||||
var lastRefill: Date
|
||||
|
||||
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
||||
let dt = now.timeIntervalSince(lastRefill)
|
||||
if dt > 0 {
|
||||
tokens = min(capacity, tokens + dt * refillPerSec)
|
||||
lastRefill = now
|
||||
}
|
||||
if tokens >= cost {
|
||||
tokens -= cost
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var senderBuckets: [String: TokenBucket] = [:]
|
||||
private var contentBuckets: [String: TokenBucket] = [:]
|
||||
|
||||
private let senderCapacity: Double
|
||||
private let senderRefill: Double
|
||||
private let contentCapacity: Double
|
||||
private let contentRefill: Double
|
||||
|
||||
init(
|
||||
senderCapacity: Double,
|
||||
senderRefillPerSec: Double,
|
||||
contentCapacity: Double,
|
||||
contentRefillPerSec: Double
|
||||
) {
|
||||
self.senderCapacity = senderCapacity
|
||||
self.senderRefill = senderRefillPerSec
|
||||
self.contentCapacity = contentCapacity
|
||||
self.contentRefill = contentRefillPerSec
|
||||
}
|
||||
|
||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
let senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
|
||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||
capacity: contentCapacity,
|
||||
tokens: contentCapacity,
|
||||
refillPerSec: contentRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
let contentAllowed = contentBucket.allow(now: now)
|
||||
contentBuckets[contentKey] = contentBucket
|
||||
|
||||
return senderAllowed && contentAllowed
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
senderBuckets.removeAll()
|
||||
contentBuckets.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
//
|
||||
// MinimalDistancePalette.swift
|
||||
// bitchat
|
||||
//
|
||||
// Lightweight palette generator that keeps peer colors evenly spaced.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class MinimalDistancePalette {
|
||||
struct Config {
|
||||
let slotCount: Int
|
||||
let avoidCenterHue: Double
|
||||
let avoidHueDelta: Double
|
||||
let saturationLight: Double
|
||||
let saturationDark: Double
|
||||
let baseBrightnessLight: Double
|
||||
let baseBrightnessDark: Double
|
||||
let ringBrightnessDeltaLight: Double
|
||||
let ringBrightnessDeltaDark: Double
|
||||
let preferredBiasWeight: Double
|
||||
let goldenStep: Int
|
||||
|
||||
init(
|
||||
slotCount: Int,
|
||||
avoidCenterHue: Double,
|
||||
avoidHueDelta: Double,
|
||||
saturationLight: Double,
|
||||
saturationDark: Double,
|
||||
baseBrightnessLight: Double,
|
||||
baseBrightnessDark: Double,
|
||||
ringBrightnessDeltaLight: Double,
|
||||
ringBrightnessDeltaDark: Double,
|
||||
preferredBiasWeight: Double = 0.05,
|
||||
goldenStep: Int = 7
|
||||
) {
|
||||
self.slotCount = slotCount
|
||||
self.avoidCenterHue = avoidCenterHue
|
||||
self.avoidHueDelta = avoidHueDelta
|
||||
self.saturationLight = saturationLight
|
||||
self.saturationDark = saturationDark
|
||||
self.baseBrightnessLight = baseBrightnessLight
|
||||
self.baseBrightnessDark = baseBrightnessDark
|
||||
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
|
||||
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
|
||||
self.preferredBiasWeight = preferredBiasWeight
|
||||
self.goldenStep = goldenStep
|
||||
}
|
||||
}
|
||||
|
||||
private struct Entry {
|
||||
let slot: Int
|
||||
let ring: Int
|
||||
let hue: Double
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private var currentSeeds: [String: String] = [:]
|
||||
private var entries: [String: Entry] = [:]
|
||||
private var previousEntries: [String: Entry] = [:]
|
||||
|
||||
init(config: Config) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func ensurePalette(for seeds: [String: String]) {
|
||||
guard seeds != currentSeeds || entries.count != seeds.count else { return }
|
||||
previousEntries = entries
|
||||
currentSeeds = seeds
|
||||
rebuildEntries()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func color(for identifier: String, isDark: Bool) -> Color? {
|
||||
guard let entry = entries[identifier] else { return nil }
|
||||
let saturation = isDark ? config.saturationDark : config.saturationLight
|
||||
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
|
||||
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
|
||||
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func reset() {
|
||||
currentSeeds.removeAll()
|
||||
entries.removeAll()
|
||||
previousEntries.removeAll()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildEntries() {
|
||||
guard !currentSeeds.isEmpty else {
|
||||
entries.removeAll()
|
||||
return
|
||||
}
|
||||
|
||||
let slotCount = max(8, config.slotCount)
|
||||
var slots: [Double] = []
|
||||
for idx in 0..<slotCount {
|
||||
let hue = Double(idx) / Double(slotCount)
|
||||
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
|
||||
continue
|
||||
}
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
for idx in 0..<slotCount {
|
||||
slots.append(Double(idx) / Double(slotCount))
|
||||
}
|
||||
}
|
||||
|
||||
func circularDistance(_ a: Double, _ b: Double) -> Double {
|
||||
let diff = abs(a - b)
|
||||
return diff > 0.5 ? 1.0 - diff : diff
|
||||
}
|
||||
|
||||
let peerIDs = currentSeeds.keys.sorted()
|
||||
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
|
||||
let seed = currentSeeds[id] ?? id
|
||||
let hash = seed.djb2()
|
||||
let index = Int(hash % UInt64(slots.count))
|
||||
return (id, index)
|
||||
})
|
||||
|
||||
var mapping: [String: Entry] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
let prior = entries.isEmpty ? previousEntries : entries
|
||||
for (id, entry) in prior {
|
||||
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
|
||||
let hue = slots[entry.slot]
|
||||
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(hue)
|
||||
}
|
||||
|
||||
let unassigned = peerIDs.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
let preferred = preferredIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred), preferred < slots.count {
|
||||
let hue = slots[preferred]
|
||||
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(hue)
|
||||
continue
|
||||
}
|
||||
|
||||
var bestSlot: Int?
|
||||
var bestScore = -Double.infinity
|
||||
for slot in 0..<slots.count where !usedSlots.contains(slot) {
|
||||
let hue = slots[slot]
|
||||
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
|
||||
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDistance + config.preferredBiasWeight * bias
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestSlot = slot
|
||||
}
|
||||
}
|
||||
|
||||
if let slot = bestSlot {
|
||||
let hue = slots[slot]
|
||||
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
|
||||
usedSlots.insert(slot)
|
||||
usedHues.append(hue)
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = peerIDs.filter { mapping[$0] == nil }
|
||||
if !remaining.isEmpty {
|
||||
for (index, id) in remaining.enumerated() {
|
||||
let preferred = preferredIndex[id] ?? 0
|
||||
let slot = (preferred + index * config.goldenStep) % slots.count
|
||||
let hue = slots[slot]
|
||||
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
|
||||
}
|
||||
}
|
||||
|
||||
entries = mapping
|
||||
}
|
||||
}
|
||||
|
||||
extension MinimalDistancePalette.Config {
|
||||
static let mesh = MinimalDistancePalette.Config(
|
||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
||||
avoidCenterHue: 30.0 / 360.0,
|
||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
||||
saturationLight: 0.70,
|
||||
saturationDark: 0.80,
|
||||
baseBrightnessLight: 0.45,
|
||||
baseBrightnessDark: 0.75,
|
||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
||||
)
|
||||
|
||||
static let nostr = MinimalDistancePalette.Config(
|
||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
||||
avoidCenterHue: 30.0 / 360.0,
|
||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
||||
saturationLight: 0.70,
|
||||
saturationDark: 0.80,
|
||||
baseBrightnessLight: 0.45,
|
||||
baseBrightnessDark: 0.75,
|
||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
//
|
||||
// PublicMessagePipeline.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol PublicMessagePipelineDelegate: AnyObject {
|
||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
|
||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
|
||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
|
||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PublicMessagePipeline {
|
||||
weak var delegate: PublicMessagePipelineDelegate?
|
||||
|
||||
private var buffer: [BitchatMessage] = []
|
||||
private var timer: Timer?
|
||||
private let baseFlushInterval: TimeInterval
|
||||
private var dynamicFlushInterval: TimeInterval
|
||||
private var recentBatchSizes: [Int] = []
|
||||
private let maxRecentBatchSamples: Int
|
||||
private let dedupWindow: TimeInterval
|
||||
private var activeChannel: ChannelID = .mesh
|
||||
|
||||
init(
|
||||
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
|
||||
maxRecentBatchSamples: Int = 10,
|
||||
dedupWindow: TimeInterval = 1.0
|
||||
) {
|
||||
self.baseFlushInterval = baseFlushInterval
|
||||
self.dynamicFlushInterval = baseFlushInterval
|
||||
self.maxRecentBatchSamples = maxRecentBatchSamples
|
||||
self.dedupWindow = dedupWindow
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func updateActiveChannel(_ channel: ChannelID) {
|
||||
activeChannel = channel
|
||||
}
|
||||
|
||||
func enqueue(_ message: BitchatMessage) {
|
||||
buffer.append(message)
|
||||
scheduleFlush()
|
||||
}
|
||||
|
||||
func flushIfNeeded() {
|
||||
flushBuffer()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension PublicMessagePipeline {
|
||||
func scheduleFlush() {
|
||||
guard timer == nil else { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.flushBuffer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flushBuffer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
guard !buffer.isEmpty else { return }
|
||||
guard let delegate = delegate else {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
return
|
||||
}
|
||||
|
||||
delegate.pipelineSetBatchingState(self, isBatching: true)
|
||||
|
||||
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
|
||||
var pending: [(message: BitchatMessage, contentKey: String)] = []
|
||||
var batchContentLatest: [String: Date] = [:]
|
||||
|
||||
for message in buffer {
|
||||
if existingIDs.contains(message.id) { continue }
|
||||
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
|
||||
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
|
||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
||||
continue
|
||||
}
|
||||
if let ts = batchContentLatest[contentKey],
|
||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
||||
continue
|
||||
}
|
||||
existingIDs.insert(message.id)
|
||||
pending.append((message, contentKey))
|
||||
batchContentLatest[contentKey] = message.timestamp
|
||||
}
|
||||
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
guard !pending.isEmpty else {
|
||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
||||
if !buffer.isEmpty { scheduleFlush() }
|
||||
return
|
||||
}
|
||||
|
||||
pending.sort { $0.message.timestamp < $1.message.timestamp }
|
||||
|
||||
var messages = delegate.pipelineCurrentMessages(self)
|
||||
let threshold = lateInsertThreshold(for: activeChannel)
|
||||
let lastTimestamp = messages.last?.timestamp ?? .distantPast
|
||||
|
||||
for item in pending {
|
||||
let message = item.message
|
||||
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
|
||||
let index = insertionIndex(for: message.timestamp, in: messages)
|
||||
if index >= messages.count {
|
||||
messages.append(message)
|
||||
} else {
|
||||
messages.insert(message, at: index)
|
||||
}
|
||||
} else {
|
||||
messages.append(message)
|
||||
}
|
||||
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
|
||||
}
|
||||
|
||||
delegate.pipeline(self, setMessages: messages)
|
||||
delegate.pipelineTrimMessages(self)
|
||||
|
||||
updateFlushInterval(withBatchSize: pending.count)
|
||||
|
||||
for item in pending {
|
||||
delegate.pipelinePrewarmMessage(self, message: item.message)
|
||||
}
|
||||
|
||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
||||
|
||||
if !buffer.isEmpty {
|
||||
scheduleFlush()
|
||||
}
|
||||
}
|
||||
|
||||
func updateFlushInterval(withBatchSize size: Int) {
|
||||
recentBatchSizes.append(size)
|
||||
if recentBatchSizes.count > maxRecentBatchSamples {
|
||||
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
|
||||
}
|
||||
let avg = recentBatchSizes.isEmpty
|
||||
? 0.0
|
||||
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
||||
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
|
||||
}
|
||||
|
||||
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
return TransportConfig.uiLateInsertThreshold
|
||||
case .location:
|
||||
return TransportConfig.uiLateInsertThresholdGeo
|
||||
}
|
||||
}
|
||||
|
||||
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
|
||||
var low = 0
|
||||
var high = messages.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if messages[mid].timestamp < timestamp {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//
|
||||
// PublicTimelineStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PublicTimelineStore {
|
||||
private var meshTimeline: [BitchatMessage] = []
|
||||
private var geohashTimelines: [String: [BitchatMessage]] = [:]
|
||||
private var pendingGeohashSystemMessages: [String] = []
|
||||
|
||||
private let meshCap: Int
|
||||
private let geohashCap: Int
|
||||
|
||||
init(meshCap: Int, geohashCap: Int) {
|
||||
self.meshCap = meshCap
|
||||
self.geohashCap = geohashCap
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let channel):
|
||||
append(message, toGeohash: channel.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
guard !timeline.contains(where: { $0.id == message.id }) else { return }
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline)
|
||||
geohashTimelines[geohash] = timeline
|
||||
}
|
||||
|
||||
/// Append message if absent, returning true when stored.
|
||||
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline)
|
||||
geohashTimelines[geohash] = timeline
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
return meshTimeline
|
||||
case .location(let channel):
|
||||
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
|
||||
geohashTimelines[channel.geohash] = cleaned
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
mutating func clear(channel: ChannelID) {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
meshTimeline.removeAll()
|
||||
case .location(let channel):
|
||||
geohashTimelines[channel.geohash] = []
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeMessage(withID id: String) -> BitchatMessage? {
|
||||
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
||||
return meshTimeline.remove(at: index)
|
||||
}
|
||||
|
||||
for key in Array(geohashTimelines.keys) {
|
||||
var timeline = geohashTimelines[key] ?? []
|
||||
if let index = timeline.firstIndex(where: { $0.id == id }) {
|
||||
let removed = timeline.remove(at: index)
|
||||
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
timeline.removeAll(where: predicate)
|
||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||
}
|
||||
|
||||
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
transform(&timeline)
|
||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||
}
|
||||
|
||||
mutating func queueGeohashSystemMessage(_ content: String) {
|
||||
pendingGeohashSystemMessages.append(content)
|
||||
}
|
||||
|
||||
mutating func drainPendingGeohashSystemMessages() -> [String] {
|
||||
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
|
||||
return pendingGeohashSystemMessages
|
||||
}
|
||||
|
||||
func geohashKeys() -> [String] {
|
||||
Array(geohashTimelines.keys)
|
||||
}
|
||||
|
||||
private mutating func trimMeshTimelineIfNeeded() {
|
||||
guard meshTimeline.count > meshCap else { return }
|
||||
meshTimeline = Array(meshTimeline.suffix(meshCap))
|
||||
}
|
||||
|
||||
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
|
||||
guard timeline.count > geohashCap else { return }
|
||||
timeline = Array(timeline.suffix(geohashCap))
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// CommandSuggestionsView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 29/10/2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CommandSuggestionsView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
|
||||
@Binding var messageText: String
|
||||
|
||||
let textColor: Color
|
||||
let backgroundColor: Color
|
||||
let secondaryTextColor: Color
|
||||
|
||||
private var filteredCommands: [CommandInfo] {
|
||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||
let isGeoPublic = locationManager.selectedChannel.isLocation
|
||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||
command.alias.starts(with: messageText.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(filteredCommands) { command in
|
||||
Button {
|
||||
messageText = command.alias + " "
|
||||
} label: {
|
||||
buttonRow(for: command)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func buttonRow(for command: CommandInfo) -> some View {
|
||||
HStack {
|
||||
Text(command.alias)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
|
||||
if let placeholder = command.placeholder {
|
||||
Text(placeholder)
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(command.description)
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, macOS 14, *)
|
||||
#Preview {
|
||||
@Previewable @State var messageText: String = "/"
|
||||
let keychain = KeychainManager()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
|
||||
CommandSuggestionsView(
|
||||
messageText: $messageText,
|
||||
textColor: .green,
|
||||
backgroundColor: .primary,
|
||||
secondaryTextColor: .secondary
|
||||
)
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
+325
-352
@@ -15,6 +15,9 @@ import AppKit
|
||||
#endif
|
||||
import UniformTypeIdentifiers
|
||||
import BitLogger
|
||||
#if canImport(PhotosUI)
|
||||
import PhotosUI
|
||||
#endif
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
@@ -35,6 +38,7 @@ struct ContentView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@ObservedObject private var notesCounter = LocationNotesCounter.shared
|
||||
@State private var messageText = ""
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@@ -43,9 +47,11 @@ struct ContentView: View {
|
||||
@State private var showPeerList = false
|
||||
@State private var showSidebar = false
|
||||
@State private var showAppInfo = false
|
||||
@State private var showCommandSuggestions = false
|
||||
@State private var commandSuggestions: [String] = []
|
||||
@State private var showMessageActions = false
|
||||
@State private var selectedMessageSender: String?
|
||||
@State private var selectedMessageSenderID: PeerID?
|
||||
@State private var selectedMessageSenderID: String?
|
||||
@FocusState private var isNicknameFieldFocused: Bool
|
||||
@State private var isAtBottomPublic: Bool = true
|
||||
@State private var isAtBottomPrivate: Bool = true
|
||||
@@ -66,19 +72,19 @@ struct ContentView: View {
|
||||
@State private var recordingDuration: TimeInterval = 0
|
||||
@State private var recordingTimer: Timer?
|
||||
@State private var recordingStartDate: Date?
|
||||
@State private var showFileImporter = false
|
||||
#if os(iOS)
|
||||
@State private var showImagePicker = false
|
||||
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
||||
#else
|
||||
@State private var showMacImagePicker = false
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var selectedPhotoPickerItem: PhotosPickerItem?
|
||||
#endif
|
||||
@State private var showAttachmentActions = false
|
||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
||||
@State private var windowCountPrivate: [String: Int] = [:]
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -122,7 +128,7 @@ struct ContentView: View {
|
||||
|
||||
|
||||
private struct PrivateHeaderContext {
|
||||
let headerPeerID: PeerID
|
||||
let headerPeerID: String
|
||||
let peer: BitchatPeer?
|
||||
let displayName: String
|
||||
let isNostrAvailable: Bool
|
||||
@@ -186,6 +192,10 @@ struct ContentView: View {
|
||||
)
|
||||
) {
|
||||
peopleSheetView
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
@@ -201,60 +211,15 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Only present image picker from main view when NOT in a sheet
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||
showImagePicker = false
|
||||
if let image = image {
|
||||
Task {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoPickerItem, matching: .images)
|
||||
.onChange(of: selectedPhotoPickerItem) { newItem in
|
||||
guard let item = newItem else { return }
|
||||
Task { await handlePhotoSelection(item) }
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Only present Mac image picker from main view when NOT in a sheet
|
||||
.sheet(isPresented: Binding(
|
||||
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showMacImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
MacImagePickerView { url in
|
||||
showMacImagePicker = false
|
||||
if let url = url {
|
||||
Task {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in
|
||||
handleImportResult(result, handler: handleImportedFile)
|
||||
}
|
||||
#endif
|
||||
.sheet(isPresented: Binding(
|
||||
get: { imagePreviewURL != nil },
|
||||
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
||||
@@ -263,6 +228,19 @@ struct ContentView: View {
|
||||
ImagePreviewView(url: url)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Attach", isPresented: $showAttachmentActions, titleVisibility: .visible) {
|
||||
#if os(iOS)
|
||||
Button("Image") {
|
||||
showAttachmentActions = false
|
||||
DispatchQueue.main.async { showPhotoPicker = true }
|
||||
}
|
||||
#endif
|
||||
Button("File") {
|
||||
showAttachmentActions = false
|
||||
DispatchQueue.main.async { showFileImporter = true }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||
Button("OK", role: .cancel) {}
|
||||
}, message: {
|
||||
@@ -283,7 +261,7 @@ struct ContentView: View {
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
if peerID.isGeoChat {
|
||||
if peerID.hasPrefix("nostr:") {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
}
|
||||
@@ -310,7 +288,7 @@ struct ContentView: View {
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
let sender = selectedMessageSender {
|
||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||
@@ -342,10 +320,10 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Message List View
|
||||
|
||||
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
||||
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
||||
let messages: [BitchatMessage] = {
|
||||
if let peerID = privatePeer {
|
||||
return viewModel.getPrivateChatMessages(for: peerID)
|
||||
if let privatePeer = privatePeer {
|
||||
return viewModel.getPrivateChatMessages(for: privatePeer)
|
||||
}
|
||||
return viewModel.messages
|
||||
}()
|
||||
@@ -484,7 +462,7 @@ struct ContentView: View {
|
||||
}
|
||||
.onChange(of: viewModel.privateChats) { _ in
|
||||
if let peerID = privatePeer,
|
||||
let messages = viewModel.privateChats[peerID],
|
||||
let messages = viewModel.privateChats[PeerID(str: peerID)],
|
||||
!messages.isEmpty {
|
||||
// If the newest private message is from me, always scroll
|
||||
let lastMsg = messages.last!
|
||||
@@ -603,12 +581,77 @@ struct ContentView: View {
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
CommandSuggestionsView(
|
||||
messageText: $messageText,
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
secondaryTextColor: secondaryTextColor
|
||||
)
|
||||
// Command suggestions
|
||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Define commands with aliases and syntax
|
||||
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/block"], "[nickname]", "block or list blocked peers"),
|
||||
(["/clear"], nil, "clear chat messages"),
|
||||
(["/hug"], "<nickname>", "send someone a warm hug"),
|
||||
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
||||
(["/slap"], "<nickname>", "slap someone with a trout"),
|
||||
(["/unblock"], "<nickname>", "unblock a peer"),
|
||||
(["/w"], nil, "see who's online")
|
||||
]
|
||||
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
|
||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/fav"], "<nickname>", "add to favorites"),
|
||||
(["/unfav"], "<nickname>", "remove from favorites")
|
||||
]
|
||||
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
|
||||
|
||||
// Build the display
|
||||
let allCommands = commandInfo
|
||||
|
||||
// Show matching commands
|
||||
ForEach(commandSuggestions, id: \.self) { command in
|
||||
// Find the command info for this suggestion
|
||||
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
|
||||
Button(action: {
|
||||
// Replace current text with selected command
|
||||
messageText = command + " "
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}) {
|
||||
HStack {
|
||||
// Show all aliases together
|
||||
Text(info.commands.joined(separator: ", "))
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
|
||||
// Show syntax if any
|
||||
if let syntax = info.syntax {
|
||||
Text(syntax)
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Show description
|
||||
Text(info.description)
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
// Recording indicator
|
||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
||||
@@ -642,11 +685,68 @@ struct ContentView: View {
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||
var commandDescriptions = [
|
||||
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
|
||||
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
|
||||
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
|
||||
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
|
||||
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
|
||||
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
|
||||
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
|
||||
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// Map of aliases to primary commands
|
||||
let aliases: [String: String] = [
|
||||
"/join": "/j",
|
||||
"/msg": "/m"
|
||||
]
|
||||
|
||||
// Filter commands, but convert aliases to primary
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
|
||||
// Also check if input matches an alias
|
||||
for (alias, primary) in aliases {
|
||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||
commandSuggestions.append(primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
@@ -663,62 +763,33 @@ struct ContentView: View {
|
||||
.padding(.bottom, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
private func handleOpenURL(_ url: URL) {
|
||||
guard url.scheme == "bitchat" else { return }
|
||||
switch url.host {
|
||||
case "user":
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||
selectedMessageSenderID = peerID
|
||||
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||
private func handleOpenURL(_ url: URL) {
|
||||
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let peerID = id.removingPercentEncoding ?? id
|
||||
selectedMessageSenderID = peerID
|
||||
|
||||
if peerID.hasPrefix("nostr") {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else {
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
||||
selectedMessageSender = name
|
||||
} else {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
||||
selectedMessageSender = nil
|
||||
selectedMessageSenderID = nil
|
||||
} else {
|
||||
showMessageActions = true
|
||||
}
|
||||
|
||||
case "geohash":
|
||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||
|
||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(channel))
|
||||
|
||||
default:
|
||||
return
|
||||
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
|
||||
selectedMessageSender = nil
|
||||
selectedMessageSenderID = nil
|
||||
} else {
|
||||
showMessageActions = true
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
||||
privatePeer: PeerID?,
|
||||
privatePeer: String?,
|
||||
isAtBottom: Binding<Bool>) {
|
||||
let targetID: String? = {
|
||||
if let peer = privatePeer,
|
||||
@@ -731,41 +802,17 @@ struct ContentView: View {
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
if let last = viewModel.messages.suffix(300).last?.id {
|
||||
return "\(contextKey)|\(last)"
|
||||
}
|
||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||
return nil
|
||||
}()
|
||||
|
||||
isAtBottom.wrappedValue = true
|
||||
|
||||
guard let target = targetID else { return }
|
||||
DispatchQueue.main.async {
|
||||
if let targetID {
|
||||
proxy.scrollTo(targetID, anchor: .bottom)
|
||||
}
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
let secondTarget: String? = {
|
||||
if let peer = privatePeer,
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
if let last = viewModel.messages.suffix(300).last?.id {
|
||||
return "\(contextKey)|\(last)"
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if let secondTarget {
|
||||
proxy.scrollTo(secondTarget, anchor: .bottom)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
// MARK: - Actions
|
||||
@@ -798,53 +845,6 @@ struct ContentView: View {
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
// Present image picker from sheet context when IN a sheet (parent-child pattern)
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||
showImagePicker = false
|
||||
if let image = image {
|
||||
Task {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showMacImagePicker) {
|
||||
MacImagePickerView { url in
|
||||
showMacImagePicker = false
|
||||
if let url = url {
|
||||
Task {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - People Sheet Views
|
||||
@@ -953,7 +953,7 @@ struct ContentView: View {
|
||||
|
||||
private var privateChatSheetView: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
||||
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
|
||||
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
@@ -977,19 +977,18 @@ struct ContentView: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
||||
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
||||
|
||||
if !privatePeerID.isGeoDM {
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
Button(action: {
|
||||
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
||||
}) {
|
||||
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||
Image(systemName: viewModel.isFavorite(peerID: headerContext.headerPeerID) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(isFavorite ? Color.yellow : textColor)
|
||||
.foregroundColor(viewModel.isFavorite(peerID: headerContext.headerPeerID) ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
isFavorite
|
||||
viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
)
|
||||
@@ -1020,7 +1019,7 @@ struct ContentView: View {
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
|
||||
.background(backgroundColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
Divider()
|
||||
@@ -1042,7 +1041,7 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
|
||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
|
||||
Button(action: {
|
||||
viewModel.showFingerprint(for: context.headerPeerID)
|
||||
}) {
|
||||
@@ -1067,7 +1066,7 @@ struct ContentView: View {
|
||||
case .offline:
|
||||
EmptyView()
|
||||
}
|
||||
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
|
||||
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
@@ -1077,7 +1076,7 @@ struct ContentView: View {
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
||||
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
@@ -1088,8 +1087,13 @@ struct ContentView: View {
|
||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
if !privatePeerID.isGeoDM {
|
||||
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
let statusPeerID: String = {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short
|
||||
}
|
||||
return context.headerPeerID
|
||||
}()
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
@@ -1122,27 +1126,33 @@ struct ContentView: View {
|
||||
.frame(height: headerHeight)
|
||||
}
|
||||
|
||||
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
||||
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
|
||||
let headerPeerID: String = {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short
|
||||
}
|
||||
return privatePeerID
|
||||
}()
|
||||
|
||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||
|
||||
let displayName: String = {
|
||||
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
|
||||
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
|
||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||
return "#\(ch.geohash)/@\(disp)"
|
||||
}
|
||||
if let name = peer?.displayName { return name }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if headerPeerID.count == 16 {
|
||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
||||
if let id = candidates.first,
|
||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
} else if let keyData = headerPeerID.noiseKey {
|
||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
||||
let fp = keyData.sha256Fingerprint()
|
||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
@@ -1154,7 +1164,7 @@ struct ContentView: View {
|
||||
|
||||
let isNostrAvailable: Bool = {
|
||||
guard let connectionState = peer?.connectionState else {
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
if let noiseKey = Data(hexString: headerPeerID),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
return true
|
||||
@@ -1284,9 +1294,10 @@ struct ContentView: View {
|
||||
showLocationNotes = true
|
||||
}) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: "note.text")
|
||||
let hasNotes = (notesCounter.count ?? 0) > 0
|
||||
Image(systemName: "long.text.page.and.pencil")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(Color.orange.opacity(0.8))
|
||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
@@ -1388,7 +1399,7 @@ struct ContentView: View {
|
||||
}) {
|
||||
Group {
|
||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||
LocationNotesView(geohash: gh)
|
||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
||||
.environmentObject(viewModel)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
@@ -1445,6 +1456,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateNotesCounterSubscription()
|
||||
if case .mesh = locationManager.selectedChannel,
|
||||
locationManager.permissionState == .authorized,
|
||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
@@ -1452,13 +1464,16 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: locationManager.selectedChannel) { _ in
|
||||
updateNotesCounterSubscription()
|
||||
if case .mesh = locationManager.selectedChannel,
|
||||
locationManager.permissionState == .authorized,
|
||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.refreshChannels()
|
||||
}
|
||||
}
|
||||
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
|
||||
.onChange(of: locationManager.permissionState) { _ in
|
||||
updateNotesCounterSubscription()
|
||||
if case .mesh = locationManager.selectedChannel,
|
||||
locationManager.permissionState == .authorized,
|
||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
@@ -1475,6 +1490,34 @@ struct ContentView: View {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Notes Counter Subscription Helper
|
||||
extension ContentView {
|
||||
private func updateNotesCounterSubscription() {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh:
|
||||
// Ensure we have a fresh one-shot location fix so building geohash is current
|
||||
if locationManager.permissionState == .authorized {
|
||||
LocationChannelManager.shared.refreshChannels()
|
||||
}
|
||||
if locationManager.permissionState == .authorized {
|
||||
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||
LocationNotesCounter.shared.subscribe(geohash: building)
|
||||
} else {
|
||||
// Keep existing subscription if we had one to avoid flicker
|
||||
// Only cancel if we have no known geohash
|
||||
if LocationNotesCounter.shared.geohash == nil {
|
||||
LocationNotesCounter.shared.cancel()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LocationNotesCounter.shared.cancel()
|
||||
}
|
||||
case .location:
|
||||
LocationNotesCounter.shared.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
// Rounded payment chip button
|
||||
@@ -1483,10 +1526,11 @@ struct ContentView: View {
|
||||
private enum MessageMedia {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
case file(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
case .voice(let url), .image(let url), .file(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -1524,6 +1568,13 @@ private extension ContentView {
|
||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||
return .image(url)
|
||||
}
|
||||
if message.content.hasPrefix("[file] ") {
|
||||
let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !filename.isEmpty else { return nil }
|
||||
let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming"
|
||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||
return .file(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1540,7 +1591,7 @@ private extension ContentView {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .read, .delivered, .failed:
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1615,6 +1666,13 @@ private extension ContentView {
|
||||
} : nil
|
||||
)
|
||||
.frame(maxWidth: 280)
|
||||
case .file(let url):
|
||||
FileAttachmentView(
|
||||
url: url,
|
||||
isSending: state.isSending,
|
||||
progress: state.progress,
|
||||
onCancel: cancelAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1673,7 +1731,7 @@ private extension ContentView {
|
||||
|
||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
privatePeer: String?,
|
||||
proxy: ScrollViewProxy) {
|
||||
let step = TransportConfig.uiWindowStepCount
|
||||
let contextKey: String = {
|
||||
@@ -1733,19 +1791,7 @@ private extension ContentView {
|
||||
}
|
||||
|
||||
private var shouldShowMediaControls: Bool {
|
||||
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
||||
return true
|
||||
}
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh:
|
||||
return true
|
||||
case .location:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowVoiceControl: Bool {
|
||||
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
||||
if viewModel.selectedPrivateChatPeer != nil {
|
||||
return true
|
||||
}
|
||||
switch locationManager.selectedChannel {
|
||||
@@ -1761,52 +1807,26 @@ private extension ContentView {
|
||||
}
|
||||
|
||||
var attachmentButton: some View {
|
||||
#if os(iOS)
|
||||
Image(systemName: "camera.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(composerAccentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.onTapGesture {
|
||||
// Tap = Photo Library
|
||||
imagePickerSourceType = .photoLibrary
|
||||
showImagePicker = true
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.3) {
|
||||
// Long press = Camera
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel("Tap for library, long press for camera")
|
||||
#else
|
||||
Button(action: { showMacImagePicker = true }) {
|
||||
Image(systemName: "photo.circle.fill")
|
||||
Button(action: { showAttachmentActions = true }) {
|
||||
Image(systemName: "paperclip.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(composerAccentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Choose photo")
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var sendOrMicButton: some View {
|
||||
let hasText = !trimmedMessageText.isEmpty
|
||||
if shouldShowVoiceControl {
|
||||
ZStack {
|
||||
micButtonView
|
||||
.opacity(hasText ? 0 : 1)
|
||||
.allowsHitTesting(!hasText)
|
||||
sendButtonView(enabled: hasText)
|
||||
.opacity(hasText ? 1 : 0)
|
||||
.allowsHitTesting(hasText)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
} else {
|
||||
return ZStack {
|
||||
micButtonView
|
||||
.opacity(hasText ? 0 : 1)
|
||||
.allowsHitTesting(!hasText)
|
||||
sendButtonView(enabled: hasText)
|
||||
.frame(width: 36, height: 36)
|
||||
.opacity(hasText ? 1 : 0)
|
||||
.allowsHitTesting(hasText)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
|
||||
private var micButtonView: some View {
|
||||
@@ -1859,7 +1879,6 @@ private extension ContentView {
|
||||
}
|
||||
|
||||
func startVoiceRecording() {
|
||||
guard shouldShowVoiceControl else { return }
|
||||
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
||||
isPreparingVoiceNote = true
|
||||
Task { @MainActor in
|
||||
@@ -1963,6 +1982,44 @@ private extension ContentView {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func handlePhotoSelection(_ item: PhotosPickerItem) async {
|
||||
defer { Task { @MainActor in selectedPhotoPickerItem = nil } }
|
||||
do {
|
||||
if let data = try await item.loadTransferable(type: Data.self) {
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("jpg")
|
||||
try data.write(to: tempURL, options: Data.WritingOptions.atomic)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: tempURL) {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Photo picker load failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func handleImportedFile(url: URL) async {
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.temporaryDirectory
|
||||
let fileName = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
|
||||
let destination = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
try fileManager.copyItem(at: url, to: destination)
|
||||
await MainActor.run {
|
||||
viewModel.sendFileAttachment(from: destination)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("File copy failed before send: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationFilesDirectory() -> URL? {
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
@@ -2106,87 +2163,3 @@ struct ImagePreviewView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Image Picker (Camera or Photo Library)
|
||||
struct ImagePickerView: UIViewControllerRepresentable {
|
||||
let sourceType: UIImagePickerController.SourceType
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = sourceType
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsEditing = false
|
||||
|
||||
// Use standard full screen - iOS handles safe areas automatically
|
||||
picker.modalPresentationStyle = .fullScreen
|
||||
|
||||
// Force dark mode to make safe area bars black instead of white
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(completion: completion)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
init(completion: @escaping (UIImage?) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
let image = info[.originalImage] as? UIImage
|
||||
completion(image)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
// MARK: - macOS Image Picker
|
||||
struct MacImagePickerView: View {
|
||||
let completion: (URL?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Choose an image")
|
||||
.font(.headline)
|
||||
|
||||
Button("Select Image") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||
panel.message = "Choose an image to send"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
completion(panel.url)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Cancel") {
|
||||
completion(nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(minWidth: 300, minHeight: 150)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
struct FingerprintView: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
let peerID: PeerID
|
||||
let peerID: String
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -65,12 +65,15 @@ struct FingerprintView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Prefer short mesh ID for session/encryption status
|
||||
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
|
||||
let statusPeerID: String = {
|
||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||
return peerID
|
||||
}()
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||
if let data = peerID.noiseKey {
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||
@@ -236,6 +239,8 @@ struct FingerprintView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(backgroundColor)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||
|
||||
@@ -125,6 +125,9 @@ struct LocationChannelsSheet: View {
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
@@ -596,7 +599,7 @@ extension LocationChannelsSheet {
|
||||
switch level {
|
||||
case .region:
|
||||
return ""
|
||||
case .building, .block, .neighborhood, .city, .province:
|
||||
default:
|
||||
return "~"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ struct LocationNotesView: View {
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
.background(backgroundColor)
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
@@ -138,7 +141,7 @@ struct LocationNotesView: View {
|
||||
String(
|
||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||
locale: .current,
|
||||
"\(geohash) ± 1", count
|
||||
geohash, count
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FileAttachmentView: View {
|
||||
private let url: URL
|
||||
private let isSending: Bool
|
||||
private let progress: Double?
|
||||
private let onCancel: (() -> Void)?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
#if os(iOS)
|
||||
@State private var showExporter = false
|
||||
#endif
|
||||
|
||||
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
|
||||
self.url = url
|
||||
self.isSending = isSending
|
||||
self.progress = progress
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
private var fileName: String {
|
||||
url.lastPathComponent
|
||||
}
|
||||
|
||||
private var normalizedProgress: Double? {
|
||||
guard let progress = progress else { return nil }
|
||||
return max(0, min(1, progress))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "doc.fill")
|
||||
.foregroundColor(Color.blue)
|
||||
.font(.bitchatSystem(size: 24))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(fileName)
|
||||
.font(.bitchatSystem(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(2)
|
||||
Text(url.lastPathComponent)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if let progress = normalizedProgress {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Color.blue)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: openFile) {
|
||||
Text("open", comment: "Button to open attached file")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule().fill(Color.blue.opacity(0.15))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
Button(action: onCancel) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 11, weight: .bold))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showExporter) {
|
||||
FileExportController(url: url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openFile() {
|
||||
#if os(iOS)
|
||||
showExporter = true
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
private struct FileExportController: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||
controller.shouldShowFileExtensions = true
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
}
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let onTapPeer: (PeerID) -> Void
|
||||
let onToggleFavorite: (PeerID) -> Void
|
||||
let onShowFingerprint: (PeerID) -> Void
|
||||
let onTapPeer: (String) -> Void
|
||||
let onToggleFavorite: (String) -> Void
|
||||
let onShowFingerprint: (String) -> Void
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
@@ -21,8 +21,8 @@ struct MeshPeerList: View {
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID)
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
// Stable visual order without mutating state here
|
||||
@@ -47,7 +47,7 @@ struct MeshPeerList: View {
|
||||
let peer = item.peer
|
||||
let isMe = item.isMe
|
||||
HStack(spacing: 4) {
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill")
|
||||
@@ -89,7 +89,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !isMe, viewModel.isPeerBlocked(peer.peerID) {
|
||||
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
@@ -105,7 +105,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
} else {
|
||||
// Offline: prefer showing verified badge from persisted fingerprints
|
||||
if let fp = viewModel.getFingerprint(for: peer.peerID),
|
||||
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.peerID) }) {
|
||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
|
||||
@@ -373,7 +373,7 @@ struct VerificationSheetView: View {
|
||||
}
|
||||
|
||||
// Optional: Remove verification for selected peer (if verified)
|
||||
if let pid = viewModel.selectedPrivateChatPeer,
|
||||
if let pid = viewModel.selectedPrivateChatPeer?.id,
|
||||
let fp = viewModel.getFingerprint(for: pid),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||
@@ -388,6 +388,10 @@ struct VerificationSheetView: View {
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onDisappear { showingScanner = false }
|
||||
}
|
||||
}
|
||||
|
||||
+204
-214
@@ -6,275 +6,265 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import XCTest
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceTests {
|
||||
private let service: MockBLEService
|
||||
private let myUUID = UUID()
|
||||
private let bus = MockBLEBus()
|
||||
final class BLEServiceTests: XCTestCase {
|
||||
|
||||
init() {
|
||||
service = MockBLEService.init(bus: bus)
|
||||
service.myPeerID = PeerID(str: myUUID.uuidString)
|
||||
var service: MockBLEService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
service = MockBLEService()
|
||||
service.myPeerID = "TEST1234"
|
||||
service.mockNickname = "TestUser"
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
service = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Basic Functionality Tests
|
||||
|
||||
@Test func serviceInitialization() {
|
||||
#expect(service.myPeerID == PeerID(str: myUUID.uuidString))
|
||||
#expect(service.myNickname == "TestUser")
|
||||
func testServiceInitialization() {
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssertEqual(service.myPeerID, "TEST1234")
|
||||
XCTAssertEqual(service.myNickname, "TestUser")
|
||||
}
|
||||
|
||||
@Test func peerConnection() {
|
||||
let somePeerID = PeerID(str: UUID().uuidString)
|
||||
func testPeerConnection() {
|
||||
// Test connecting a peer
|
||||
service.simulateConnectedPeer("PEER5678")
|
||||
XCTAssertTrue(service.isPeerConnected("PEER5678"))
|
||||
XCTAssertEqual(service.getConnectedPeers().count, 1)
|
||||
|
||||
service.simulateConnectedPeer(somePeerID)
|
||||
#expect(service.isPeerConnected(somePeerID))
|
||||
#expect(service.getConnectedPeers().count == 1)
|
||||
|
||||
service.simulateDisconnectedPeer(somePeerID)
|
||||
#expect(!service.isPeerConnected(somePeerID))
|
||||
#expect(service.getConnectedPeers().count == 0)
|
||||
// Test disconnecting a peer
|
||||
service.simulateDisconnectedPeer("PEER5678")
|
||||
XCTAssertFalse(service.isPeerConnected("PEER5678"))
|
||||
XCTAssertEqual(service.getConnectedPeers().count, 0)
|
||||
}
|
||||
|
||||
@Test func multiplePeerConnections() {
|
||||
let peerID1 = PeerID(str: UUID().uuidString)
|
||||
let peerID2 = PeerID(str: UUID().uuidString)
|
||||
let peerID3 = PeerID(str: UUID().uuidString)
|
||||
|
||||
service.simulateConnectedPeer(peerID1)
|
||||
service.simulateConnectedPeer(peerID2)
|
||||
service.simulateConnectedPeer(peerID3)
|
||||
func testMultiplePeerConnections() {
|
||||
service.simulateConnectedPeer("PEER1")
|
||||
service.simulateConnectedPeer("PEER2")
|
||||
service.simulateConnectedPeer("PEER3")
|
||||
|
||||
#expect(service.getConnectedPeers().count == 3)
|
||||
#expect(service.isPeerConnected(peerID1))
|
||||
#expect(service.isPeerConnected(peerID2))
|
||||
#expect(service.isPeerConnected(peerID3))
|
||||
XCTAssertEqual(service.getConnectedPeers().count, 3)
|
||||
XCTAssertTrue(service.isPeerConnected("PEER1"))
|
||||
XCTAssertTrue(service.isPeerConnected("PEER2"))
|
||||
XCTAssertTrue(service.isPeerConnected("PEER3"))
|
||||
|
||||
service.simulateDisconnectedPeer(peerID2)
|
||||
#expect(service.getConnectedPeers().count == 2)
|
||||
#expect(!service.isPeerConnected(peerID2))
|
||||
service.simulateDisconnectedPeer("PEER2")
|
||||
XCTAssertEqual(service.getConnectedPeers().count, 2)
|
||||
XCTAssertFalse(service.isPeerConnected("PEER2"))
|
||||
}
|
||||
|
||||
// MARK: - Message Sending Tests
|
||||
|
||||
@Test func sendPublicMessage() async throws {
|
||||
try await confirmation { receivedPublicMessage in
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
#expect(message.content == "Hello, world!")
|
||||
#expect(message.sender == "TestUser")
|
||||
#expect(!message.isPrivate)
|
||||
receivedPublicMessage()
|
||||
}
|
||||
service.delegate = delegate
|
||||
service.sendMessage("Hello, world!")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
func testSendPublicMessage() {
|
||||
let expectation = XCTestExpectation(description: "Message sent")
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
XCTAssertEqual(message.content, "Hello, world!")
|
||||
XCTAssertEqual(message.sender, "TestUser")
|
||||
XCTAssertFalse(message.isPrivate)
|
||||
expectation.fulfill()
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
service.delegate = delegate
|
||||
|
||||
service.sendMessage("Hello, world!")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertEqual(service.sentMessages.count, 1)
|
||||
}
|
||||
|
||||
@Test func sendPrivateMessage() async throws {
|
||||
try await confirmation { receivedPrivateMessage in
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
#expect(message.content == "Secret message")
|
||||
#expect(message.sender == "TestUser")
|
||||
#expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
|
||||
#expect(message.isPrivate)
|
||||
#expect(message.recipientNickname == "Bob")
|
||||
receivedPrivateMessage()
|
||||
}
|
||||
service.delegate = delegate
|
||||
service.sendPrivateMessage(
|
||||
"Secret message",
|
||||
to: PeerID(str: UUID().uuidString),
|
||||
recipientNickname: "Bob",
|
||||
messageID: "MSG123"
|
||||
)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
func testSendPrivateMessage() {
|
||||
let expectation = XCTestExpectation(description: "Private message sent")
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
XCTAssertEqual(message.content, "Secret message")
|
||||
XCTAssertEqual(message.sender, "TestUser")
|
||||
XCTAssertTrue(message.isPrivate)
|
||||
XCTAssertEqual(message.recipientNickname, "Bob")
|
||||
expectation.fulfill()
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
service.delegate = delegate
|
||||
|
||||
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertEqual(service.sentMessages.count, 1)
|
||||
}
|
||||
|
||||
@Test func sendMessageWithMentions() async throws {
|
||||
try await confirmation { receivedMessageWithMentions in
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
#expect(message.content == "@alice @bob check this out")
|
||||
#expect(message.mentions == ["alice", "bob"])
|
||||
receivedMessageWithMentions()
|
||||
}
|
||||
service.delegate = delegate
|
||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
func testSendMessageWithMentions() {
|
||||
let expectation = XCTestExpectation(description: "Message with mentions sent")
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
XCTAssertEqual(message.content, "@alice @bob check this out")
|
||||
XCTAssertEqual(message.mentions, ["alice", "bob"])
|
||||
expectation.fulfill()
|
||||
}
|
||||
service.delegate = delegate
|
||||
|
||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Message Reception Tests
|
||||
|
||||
@Test func simulateIncomingMessage() async throws {
|
||||
try await confirmation { receiveMessage in
|
||||
let peerID = PeerID(str: UUID().uuidString)
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
#expect(message.content == "Incoming message")
|
||||
#expect(message.sender == "RemoteUser")
|
||||
#expect(message.senderPeerID == peerID)
|
||||
receiveMessage()
|
||||
}
|
||||
service.delegate = delegate
|
||||
|
||||
let incomingMessage = BitchatMessage(
|
||||
id: "MSG456",
|
||||
sender: "RemoteUser",
|
||||
content: "Incoming message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
service.simulateIncomingMessage(incomingMessage)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
func testSimulateIncomingMessage() {
|
||||
let expectation = XCTestExpectation(description: "Message received")
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
XCTAssertEqual(message.content, "Incoming message")
|
||||
XCTAssertEqual(message.sender, "RemoteUser")
|
||||
expectation.fulfill()
|
||||
}
|
||||
service.delegate = delegate
|
||||
|
||||
let incomingMessage = BitchatMessage(
|
||||
id: "MSG456",
|
||||
sender: "RemoteUser",
|
||||
content: "Incoming message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "REMOTE123",
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
service.simulateIncomingMessage(incomingMessage)
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
|
||||
@Test func simulateIncomingPacket() async throws {
|
||||
try await confirmation { processPacket in
|
||||
let peerID = PeerID(str: UUID().uuidString)
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
#expect(message.content == "Packet message")
|
||||
#expect(message.senderPeerID == peerID)
|
||||
processPacket()
|
||||
}
|
||||
service.delegate = delegate
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "MSG789",
|
||||
sender: "PacketSender",
|
||||
content: "Packet message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload")
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: peerID.id.data(using: .utf8)!,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
func testSimulateIncomingPacket() {
|
||||
let expectation = XCTestExpectation(description: "Packet processed")
|
||||
|
||||
let delegate = MockBitchatDelegate { message in
|
||||
XCTAssertEqual(message.content, "Packet message")
|
||||
expectation.fulfill()
|
||||
}
|
||||
service.delegate = delegate
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "MSG789",
|
||||
sender: "PacketSender",
|
||||
content: "Packet message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "PACKET123",
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
guard let payload = message.toBinaryPayload() else {
|
||||
XCTFail("Failed to create binary payload")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: "PACKET123".data(using: .utf8)!,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Peer Nickname Tests
|
||||
|
||||
@Test func getPeerNicknames() {
|
||||
let peerID1 = PeerID(str: UUID().uuidString)
|
||||
let peerID2 = PeerID(str: UUID().uuidString)
|
||||
|
||||
service.simulateConnectedPeer(peerID1)
|
||||
service.simulateConnectedPeer(peerID2)
|
||||
func testGetPeerNicknames() {
|
||||
service.simulateConnectedPeer("PEER1")
|
||||
service.simulateConnectedPeer("PEER2")
|
||||
|
||||
let nicknames = service.getPeerNicknames()
|
||||
#expect(nicknames.count == 2)
|
||||
#expect(nicknames[peerID1] == "MockPeer_\(peerID1)")
|
||||
#expect(nicknames[peerID2] == "MockPeer_\(peerID2)")
|
||||
XCTAssertEqual(nicknames.count, 2)
|
||||
XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1")
|
||||
XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2")
|
||||
}
|
||||
|
||||
// MARK: - Service State Tests
|
||||
|
||||
@Test func startStopServices() {
|
||||
func testStartStopServices() {
|
||||
// These are mock implementations, just ensure they don't crash
|
||||
service.startServices()
|
||||
service.stopServices()
|
||||
let somePeerID = PeerID(str: UUID().uuidString)
|
||||
service.simulateConnectedPeer(somePeerID)
|
||||
#expect(service.isPeerConnected(somePeerID))
|
||||
|
||||
// Service should still be functional after start/stop
|
||||
service.simulateConnectedPeer("PEER999")
|
||||
XCTAssertTrue(service.isPeerConnected("PEER999"))
|
||||
}
|
||||
|
||||
// MARK: - Message Delivery Handler Tests
|
||||
|
||||
@Test func messageDeliveryHandler() async throws {
|
||||
try await confirmation { deliveryHandler in
|
||||
service.packetDeliveryHandler = { packet in
|
||||
if let msg = BitchatMessage(packet.payload) {
|
||||
#expect(msg.content == "Test delivery")
|
||||
deliveryHandler()
|
||||
}
|
||||
func testMessageDeliveryHandler() {
|
||||
let expectation = XCTestExpectation(description: "Delivery handler called")
|
||||
|
||||
service.packetDeliveryHandler = { packet in
|
||||
if let msg = BitchatMessage(packet.payload) {
|
||||
XCTAssertEqual(msg.content, "Test delivery")
|
||||
expectation.fulfill()
|
||||
}
|
||||
service.sendMessage("Test delivery")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
}
|
||||
|
||||
service.sendMessage("Test delivery")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
|
||||
@Test func packetDeliveryHandler() async throws {
|
||||
try await confirmation("Packet handler called") { packetHandler in
|
||||
let peerID = PeerID(str: UUID().uuidString)
|
||||
|
||||
service.packetDeliveryHandler = { packet in
|
||||
#expect(packet.type == 0x01)
|
||||
#expect(packet.senderID == Data(peerID.id.utf8))
|
||||
packetHandler()
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "PKT123",
|
||||
sender: "TestSender",
|
||||
content: "Test packet",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to create payload")
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: peerID.id.data(using: .utf8)!,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
func testPacketDeliveryHandler() {
|
||||
let expectation = XCTestExpectation(description: "Packet handler called")
|
||||
|
||||
service.packetDeliveryHandler = { packet in
|
||||
XCTAssertEqual(packet.type, 0x01)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "PKT123",
|
||||
sender: "TestSender",
|
||||
content: "Test packet",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "TEST123",
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
guard let payload = message.toBinaryPayload() else {
|
||||
XCTFail("Failed to create payload")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: "TEST123".data(using: .utf8)!,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,5 +288,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct CommandProcessorTests {
|
||||
private var identityManager = MockIdentityManager(MockKeychain())
|
||||
final class CommandProcessorTests: XCTestCase {
|
||||
|
||||
var identityManager: MockIdentityManager!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Provide a minimal identity manager for commands that query identity/block lists
|
||||
identityManager = MockIdentityManager(MockKeychain())
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
identityManager = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func slapNotFoundGrammar() {
|
||||
func test_slap_notFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "cannot slap system: not found")
|
||||
XCTAssertEqual(message, "cannot slap system: not found")
|
||||
default:
|
||||
Issue.record("Expected error result")
|
||||
XCTFail("Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func hugNotFoundGrammar() {
|
||||
func test_hug_notFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/hug @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "cannot hug system: not found")
|
||||
XCTAssertEqual(message, "cannot hug system: not found")
|
||||
default:
|
||||
Issue.record("Expected error result")
|
||||
XCTFail("Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
@Test func slapUsageMessage() {
|
||||
func test_slap_usageMessage() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "usage: /slap <nickname>")
|
||||
XCTAssertEqual(message, "usage: /slap <nickname>")
|
||||
default:
|
||||
Issue.record("Expected error result for usage message")
|
||||
XCTFail("Expected error result for usage message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,21 @@ import CryptoKit
|
||||
import struct Foundation.UUID
|
||||
@testable import bitchat
|
||||
|
||||
// TODO: Remove once MockBLEService is refactored to fix race condition
|
||||
@Suite(.serialized)
|
||||
struct PrivateChatE2ETests {
|
||||
|
||||
private let alice: MockBLEService
|
||||
private let bob: MockBLEService
|
||||
private let charlie: MockBLEService
|
||||
private let mockKeychain = MockKeychain()
|
||||
private let bus = MockBLEBus()
|
||||
private let mockKeychain: MockKeychain
|
||||
|
||||
init() {
|
||||
// Create services with unique peer IDs to avoid any collision
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||
mockKeychain = MockKeychain()
|
||||
}
|
||||
|
||||
// MARK: - Basic Private Messaging Tests
|
||||
@@ -51,7 +53,7 @@ struct PrivateChatE2ETests {
|
||||
)
|
||||
|
||||
// Wait a bit to ensure message would have been delivered if it was going to be
|
||||
try? await sleep(0.1)
|
||||
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
||||
}
|
||||
|
||||
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||
@@ -169,7 +171,7 @@ struct PrivateChatE2ETests {
|
||||
// Send encrypted private message
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testMessage1,
|
||||
to: bob.peerID,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
@@ -186,11 +188,11 @@ struct PrivateChatE2ETests {
|
||||
// Bob relays private messages for Charlie
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
if let recipientID = packet.recipientID,
|
||||
PeerID(data: recipientID) == charlie.peerID {
|
||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||
// Relay to Charlie
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
charlie.simulateIncomingPacket(relayPacket)
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +235,7 @@ struct PrivateChatE2ETests {
|
||||
for i in 0..<messageCount {
|
||||
alice.sendPrivateMessage(
|
||||
"Private message \(i)",
|
||||
to: bob.peerID,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
@@ -252,7 +254,7 @@ struct PrivateChatE2ETests {
|
||||
|
||||
alice.sendPrivateMessage(
|
||||
TestConstants.testLongMessage,
|
||||
to: bob.peerID,
|
||||
to: TestConstants.testPeerID2,
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,22 +10,22 @@ import Testing
|
||||
import struct Foundation.UUID
|
||||
@testable import bitchat
|
||||
|
||||
@Suite(.serialized)
|
||||
struct PublicChatE2ETests {
|
||||
|
||||
private let alice: MockBLEService
|
||||
private let bob: MockBLEService
|
||||
private let charlie: MockBLEService
|
||||
private let david: MockBLEService
|
||||
private let bus = MockBLEBus()
|
||||
|
||||
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||
|
||||
init() {
|
||||
// Create mock services with unique peer IDs to avoid any collision
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)
|
||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
||||
}
|
||||
|
||||
// MARK: - Basic Broadcasting Tests
|
||||
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
// Don't relay own messages
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
guard message.senderPeerID?.id != node.peerID else { return }
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
|
||||
@@ -34,7 +34,7 @@ struct FragmentationTests {
|
||||
ble.delegate = capture
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
let remoteShortID = PeerID(str: "1122334455667788")
|
||||
let remoteShortID: PeerID = "1122334455667788"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
@@ -45,15 +45,15 @@ struct FragmentationTests {
|
||||
|
||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||
for (i, fragment) in shuffled.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||
@@ -69,7 +69,7 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let remoteShortID: PeerID = "A1B2C3D4E5F60708"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
|
||||
@@ -79,75 +79,19 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
for (i, fragment) in frags.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||
}
|
||||
|
||||
@Test("Max-sized file transfer survives reassembly")
|
||||
func maxSizedFileTransferSurvivesReassembly() async throws {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
|
||||
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: "limit.bin",
|
||||
fileSize: UInt64(fileContent.count),
|
||||
mimeType: "application/octet-stream",
|
||||
content: fileContent
|
||||
)
|
||||
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: Data(hexString: remoteID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encoded,
|
||||
signature: nil,
|
||||
ttl: 7,
|
||||
version: 2
|
||||
)
|
||||
|
||||
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
|
||||
#expect(!fragments.isEmpty)
|
||||
|
||||
for (i, fragment) in fragments.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
||||
}
|
||||
}
|
||||
|
||||
try await sleep(1.0)
|
||||
|
||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||
#expect(message.content.hasPrefix("[file]"))
|
||||
|
||||
if let fileName = message.content.split(separator: " ").last {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
|
||||
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
|
||||
let url = incoming.appendingPathComponent(String(fileName))
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Invalid fragment header is ignored")
|
||||
func invalidFragmentHeaderIsIgnored() async throws {
|
||||
@@ -159,7 +103,7 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let remoteShortID = PeerID(str: "0011223344556677")
|
||||
let remoteShortID: PeerID = "0011223344556677"
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
||||
let fragments = fragmentPacket(original, fragmentSize: 250)
|
||||
|
||||
@@ -180,16 +124,16 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
for (i, fragment) in corrupted.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||
|
||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||
#expect(capture.publicMessages.isEmpty)
|
||||
}
|
||||
@@ -198,10 +142,7 @@ struct FragmentationTests {
|
||||
extension FragmentationTests {
|
||||
private final class CaptureDelegate: BitchatDelegate {
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||
var receivedMessages: [BitchatMessage] = []
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
receivedMessages.append(message)
|
||||
}
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
@@ -209,7 +150,7 @@ extension FragmentationTests {
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
publicMessages.append((peerID, nickname, content))
|
||||
}
|
||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||
@@ -232,8 +173,8 @@ extension FragmentationTests {
|
||||
}
|
||||
|
||||
// Helper: fragment a packet using the same header format BLEService expects
|
||||
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
|
||||
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
||||
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
|
||||
let fullData = packet.toBinaryData() ?? Data()
|
||||
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
||||
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import Testing
|
||||
import struct Foundation.Data
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct GCSFilterTests {
|
||||
@Test func buildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||
final class GCSFilterTests: XCTestCase {
|
||||
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||
let id = Data(repeating: 0xAB, count: 16)
|
||||
let ids = Array(repeating: id, count: 64)
|
||||
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||
#expect(params.m >= 1)
|
||||
XCTAssertGreaterThanOrEqual(params.m, 1)
|
||||
|
||||
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||
#expect(decoded.count <= 1)
|
||||
XCTAssertLessThanOrEqual(decoded.count, 1)
|
||||
}
|
||||
|
||||
@Test func bucketAvoidsZeroCandidate() {
|
||||
func testBucketAvoidsZeroCandidate() {
|
||||
let id = Data(repeating: 0x01, count: 16)
|
||||
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
||||
#expect(bucket != 0)
|
||||
#expect(bucket < 2)
|
||||
XCTAssertNotEqual(bucket, 0)
|
||||
XCTAssertLessThan(bucket, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct GeohashBookmarksStoreTests {
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let storage = UserDefaults(suiteName: UUID().uuidString)!
|
||||
private let store: GeohashBookmarksStore
|
||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
let storeKey = "locationChannel.bookmarks"
|
||||
var storage: UserDefaults!
|
||||
var store: GeohashBookmarksStore!
|
||||
|
||||
init() {
|
||||
store = GeohashBookmarksStore(storage: storage)
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Unique instance for each test to avoid race condition
|
||||
storage = UserDefaults(suiteName: UUID().uuidString)
|
||||
store = GeohashBookmarksStore(storage: storage!)
|
||||
}
|
||||
|
||||
@Test func toggleAndNormalize() {
|
||||
override func tearDown() {
|
||||
storage.removeObject(forKey: storeKey)
|
||||
store._resetForTesting()
|
||||
store = nil
|
||||
storage = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testToggleAndNormalize() {
|
||||
// Start clean
|
||||
#expect(store.bookmarks.isEmpty)
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
|
||||
// Add with mixed case and hash prefix
|
||||
store.toggle("#U4PRUY")
|
||||
#expect(store.isBookmarked("u4pruy"))
|
||||
#expect(store.bookmarks.first == "u4pruy")
|
||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
||||
|
||||
// Toggling again removes
|
||||
store.toggle("u4pruy")
|
||||
#expect(!store.isBookmarked("u4pruy"))
|
||||
#expect(store.bookmarks.isEmpty)
|
||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
}
|
||||
|
||||
@Test func persistenceWritten() throws {
|
||||
func testPersistenceWritten() throws {
|
||||
store.toggle("ezs42")
|
||||
store.toggle("u4pruy")
|
||||
|
||||
// Verify persisted JSON contains both (order not enforced here)
|
||||
let data = try #require(storage.data(forKey: storeKey), "No persisted data found")
|
||||
guard let data = storage.data(forKey: storeKey) else {
|
||||
XCTFail("No persisted data found")
|
||||
return
|
||||
}
|
||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||
#expect(arr.contains("ezs42"))
|
||||
#expect(arr.contains("u4pruy"))
|
||||
XCTAssertTrue(arr.contains("ezs42"))
|
||||
XCTAssertTrue(arr.contains("u4pruy"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct GossipSyncManagerTests {
|
||||
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||
final class GossipSyncManagerTests: XCTestCase {
|
||||
func testConcurrentPacketIntakeAndSyncRequest() {
|
||||
let manager = GossipSyncManager(myPeerID: "0102030405060708")
|
||||
let delegate = RecordingDelegate()
|
||||
let sendExpectation = expectation(description: "sync request sent")
|
||||
delegate.onSend = { sendExpectation.fulfill() }
|
||||
manager.delegate = delegate
|
||||
|
||||
try await confirmation("sync request sent") { sent in
|
||||
delegate.onSend = {
|
||||
sent()
|
||||
}
|
||||
let iterations = 200
|
||||
let group = DispatchGroup()
|
||||
|
||||
let iterations = 200
|
||||
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||
|
||||
for i in 0..<iterations {
|
||||
for i in 0..<iterations {
|
||||
group.enter()
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: senderID,
|
||||
senderID: Data(hexString: "1122334455667788") ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: 1_000_000 + UInt64(i),
|
||||
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||
@@ -30,26 +26,35 @@ struct GossipSyncManagerTests {
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(packet)
|
||||
try await sleep(0.001)
|
||||
Thread.sleep(forTimeInterval: 0.001)
|
||||
group.leave()
|
||||
}
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
try await sleep(0.002)
|
||||
}
|
||||
|
||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||
#expect(lastPacket.type == MessageType.requestSync.rawValue)
|
||||
#expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
|
||||
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
|
||||
}
|
||||
|
||||
group.wait()
|
||||
wait(for: [sendExpectation], timeout: 2.0)
|
||||
|
||||
guard let lastPacket = delegate.lastPacket else {
|
||||
XCTFail("Expected sync packet to be sent")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
||||
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
||||
}
|
||||
|
||||
@Test func staleAnnouncementsArePurgedWithMessages() throws {
|
||||
func testStaleAnnouncementsArePurgedWithMessages() {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.stalePeerCleanupIntervalSeconds = 0
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config)
|
||||
let peerHex = "0011223344556677"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let senderData = Data(hexString: peerHex) ?? Data()
|
||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let announcePacket = BitchatPacket(
|
||||
@@ -77,24 +82,24 @@ struct GossipSyncManagerTests {
|
||||
|
||||
// Flush queue without triggering stale cleanup yet
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)
|
||||
|
||||
XCTAssertTrue(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 1)
|
||||
|
||||
// Run cleanup past the timeout
|
||||
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
|
||||
manager._performMaintenanceSynchronously(now: future)
|
||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0)
|
||||
}
|
||||
|
||||
@Test func ignoresAnnounceOlderThanStaleTimeout() throws {
|
||||
func testIgnoresAnnounceOlderThanStaleTimeout() {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
config.maxMessageAgeSeconds = 100
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config)
|
||||
let peerHex = "8899aabbccddeeff"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let senderData = Data(hexString: peerHex) ?? Data()
|
||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||
|
||||
let freshMessage = BitchatPacket(
|
||||
@@ -122,141 +127,19 @@ struct GossipSyncManagerTests {
|
||||
|
||||
manager._performMaintenanceSynchronously()
|
||||
|
||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsTypedSyncRequests() throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 10
|
||||
config.fragmentCapacity = 5
|
||||
config.fileTransferCapacity = 4
|
||||
config.messageSyncIntervalSeconds = 1
|
||||
config.fragmentSyncIntervalSeconds = 1
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "1122334455667788"))
|
||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let announcePacket = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let messagePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0x01]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let fragmentPacket = BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0xAA]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let filePacket = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0xBB]),
|
||||
signature: nil,
|
||||
ttl: 1,
|
||||
version: 2
|
||||
)
|
||||
|
||||
manager.onPublicPacketSeen(announcePacket)
|
||||
manager.onPublicPacketSeen(messagePacket)
|
||||
manager.onPublicPacketSeen(fragmentPacket)
|
||||
manager.onPublicPacketSeen(filePacket)
|
||||
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 3)
|
||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||
#expect(decoded.count == 3)
|
||||
#expect(decoded[0].types == .publicMessages)
|
||||
#expect(decoded[1].types == .fragment)
|
||||
#expect(decoded[2].types == .fileTransfer)
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 5
|
||||
config.fragmentCapacity = 5
|
||||
config.fileTransferCapacity = 0
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let messagePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0x10]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
let fragmentPacket = BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0x20]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
manager.onPublicPacketSeen(messagePacket)
|
||||
manager.onPublicPacketSeen(fragmentPacket)
|
||||
|
||||
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await sleep(0.01)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
var onSend: (() -> Void)?
|
||||
private(set) var lastPacket: BitchatPacket?
|
||||
private(set) var packets: [BitchatPacket] = []
|
||||
private let lock = NSLock()
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
lastPacket = packet
|
||||
packets.append(packet)
|
||||
lock.unlock()
|
||||
onSend?()
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
//
|
||||
// InputValidatorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct InputValidatorTests {
|
||||
|
||||
// MARK: - Basic Validation Tests
|
||||
|
||||
@Test func validStringPassesValidation() throws {
|
||||
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
|
||||
#expect(result == "Hello World")
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsNil() throws {
|
||||
let result = InputValidator.validateUserString("", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyStringReturnsNil() throws {
|
||||
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringExceedingMaxLengthReturnsNil() throws {
|
||||
let longString = String(repeating: "a", count: 101)
|
||||
let result = InputValidator.validateUserString(longString, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringAtMaxLengthIsAccepted() throws {
|
||||
let exactString = String(repeating: "a", count: 100)
|
||||
let result = InputValidator.validateUserString(exactString, maxLength: 100)
|
||||
#expect(result == exactString)
|
||||
}
|
||||
|
||||
@Test func whitespaceIsTrimmed() throws {
|
||||
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
|
||||
#expect(result == "Hello")
|
||||
}
|
||||
|
||||
// MARK: - Control Character Tests
|
||||
|
||||
@Test func nullCharacterIsRejected() throws {
|
||||
let stringWithNull = "Hello\u{0000}World"
|
||||
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func bellCharacterIsRejected() throws {
|
||||
let stringWithBell = "Hello\u{0007}World"
|
||||
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func backspaceCharacterIsRejected() throws {
|
||||
let stringWithBackspace = "Hello\u{0008}World"
|
||||
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func escapeCharacterIsRejected() throws {
|
||||
let stringWithEscape = "Hello\u{001B}World"
|
||||
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func deleteCharacterIsRejected() throws {
|
||||
let stringWithDelete = "Hello\u{007F}World"
|
||||
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func multipleControlCharactersAreRejected() throws {
|
||||
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
|
||||
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
// MARK: - Unicode and Special Character Tests
|
||||
|
||||
@Test func emojiIsAccepted() throws {
|
||||
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
|
||||
#expect(result == "Hello 👋 World")
|
||||
}
|
||||
|
||||
@Test func unicodeCharactersAreAccepted() throws {
|
||||
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
|
||||
#expect(result == "Hello 世界 مرحبا")
|
||||
}
|
||||
|
||||
@Test func specialCharactersAreAccepted() throws {
|
||||
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
|
||||
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
|
||||
}
|
||||
|
||||
// MARK: - Nickname Validation Tests
|
||||
|
||||
@Test func validNicknameIsAccepted() throws {
|
||||
let result = InputValidator.validateNickname("Alice")
|
||||
#expect(result == "Alice")
|
||||
}
|
||||
|
||||
@Test func nicknameWithEmojiIsAccepted() throws {
|
||||
let result = InputValidator.validateNickname("Alice 🚀")
|
||||
#expect(result == "Alice 🚀")
|
||||
}
|
||||
|
||||
@Test func nicknameTooLongIsRejected() throws {
|
||||
let longNickname = String(repeating: "a", count: 51)
|
||||
let result = InputValidator.validateNickname(longNickname)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func nicknameAtMaxLengthIsAccepted() throws {
|
||||
let exactNickname = String(repeating: "a", count: 50)
|
||||
let result = InputValidator.validateNickname(exactNickname)
|
||||
#expect(result == exactNickname)
|
||||
}
|
||||
|
||||
@Test func nicknameWithControlCharacterIsRejected() throws {
|
||||
let result = InputValidator.validateNickname("Alice\u{0000}")
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Validation Tests
|
||||
|
||||
@Test func currentTimestampIsValid() throws {
|
||||
let now = Date()
|
||||
let result = InputValidator.validateTimestamp(now)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampWithinOneHourIsValid() throws {
|
||||
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
||||
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampTwoHoursAgoIsInvalid() throws {
|
||||
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
|
||||
let result = InputValidator.validateTimestamp(twoHoursAgo)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampTwoHoursInFutureIsInvalid() throws {
|
||||
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
|
||||
let result = InputValidator.validateTimestamp(twoHoursFromNow)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampAtOneHourBoundaryIsValid() throws {
|
||||
// Just slightly within the one-hour window
|
||||
let almostOneHourAgo = Date().addingTimeInterval(-3599)
|
||||
let result = InputValidator.validateTimestamp(almostOneHourAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
@Test func singleCharacterStringIsAccepted() throws {
|
||||
let result = InputValidator.validateUserString("a", maxLength: 100)
|
||||
#expect(result == "a")
|
||||
}
|
||||
|
||||
@Test func stringWithOnlyNewlinesIsRejected() throws {
|
||||
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
|
||||
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
|
||||
#expect(result == "Hello")
|
||||
}
|
||||
|
||||
@Test func stringWithLeadingControlCharacterIsRejected() throws {
|
||||
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringWithTrailingControlCharacterIsRejected() throws {
|
||||
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
}
|
||||
@@ -6,31 +6,52 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct IntegrationTests {
|
||||
final class IntegrationTests: XCTestCase {
|
||||
|
||||
private var helper = TestNetworkHelper()
|
||||
var nodes: [String: MockBLEService] = [:]
|
||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||
private var mockKeychain: MockKeychain!
|
||||
|
||||
init() {
|
||||
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Use the in-memory test bus with autoFlood enabled to simulate
|
||||
// broadcast propagation across a larger mesh. Integration-only.
|
||||
MockBLEService.resetTestBus()
|
||||
MockBLEService.autoFloodEnabled = true
|
||||
mockKeychain = MockKeychain()
|
||||
|
||||
// Create a network of nodes
|
||||
createNode("Alice", peerID: TestConstants.testPeerID1)
|
||||
createNode("Bob", peerID: TestConstants.testPeerID2)
|
||||
createNode("Charlie", peerID: TestConstants.testPeerID3)
|
||||
createNode("David", peerID: TestConstants.testPeerID4)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Disable flooding to avoid cross-test interference
|
||||
MockBLEService.autoFloodEnabled = false
|
||||
nodes.removeAll()
|
||||
noiseManagers.removeAll()
|
||||
mockKeychain = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Multi-Peer Scenarios
|
||||
|
||||
@Test func fullMeshCommunication() async throws {
|
||||
helper.connectFullMesh()
|
||||
func testFullMeshCommunication() {
|
||||
// Create full mesh - everyone connected to everyone
|
||||
connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "All nodes communicate")
|
||||
var messageMatrix: [String: Set<String>] = [:]
|
||||
for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }
|
||||
|
||||
for (receiverName, receiver) in helper.nodes {
|
||||
// Track all receivers; parse sender name from message content "Hello from <Name>"
|
||||
for (senderName, _) in nodes { messageMatrix[senderName] = [] }
|
||||
for (receiverName, receiver) in nodes {
|
||||
receiver.messageDeliveryHandler = { message in
|
||||
let parts = message.content.components(separatedBy: " ")
|
||||
if let last = parts.last, message.content.contains("Hello from") {
|
||||
@@ -41,336 +62,370 @@ struct IntegrationTests {
|
||||
}
|
||||
}
|
||||
|
||||
for (name, node) in helper.nodes {
|
||||
node.sendMessage("Hello from \(name)")
|
||||
// Each node sends a message
|
||||
for (name, node) in nodes {
|
||||
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
|
||||
}
|
||||
|
||||
// Each sender should have reached all other nodes
|
||||
for (sender, receivers) in messageMatrix {
|
||||
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
|
||||
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func dynamicTopologyChanges() async throws {
|
||||
// Start with Alice -> Bob -> Charlie
|
||||
helper.connect("Alice", "Bob")
|
||||
helper.connect("Bob", "Charlie")
|
||||
|
||||
try await confirmation("Topology changes handled") { receiveMessage in
|
||||
var phase = 1
|
||||
|
||||
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||
if phase == 1 && message.sender == "Alice" {
|
||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||
helper.disconnect("Alice", "Bob")
|
||||
helper.disconnect("Bob", "Charlie")
|
||||
helper.connect("Alice", "Charlie")
|
||||
phase = 2
|
||||
|
||||
// Send another message
|
||||
helper.nodes["Alice"]!.sendMessage("Direct message")
|
||||
} else if phase == 2 && message.content == "Direct message" {
|
||||
receiveMessage()
|
||||
}
|
||||
// Wait and verify
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
// Each sender should have reached all other nodes
|
||||
for (sender, receivers) in messageMatrix {
|
||||
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
|
||||
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
|
||||
}
|
||||
|
||||
// Allow relay handler to be set before first send
|
||||
try await sleep(0.05)
|
||||
helper.nodes["Alice"]!.sendMessage("Relayed message")
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
@Test func networkPartitionRecovery() async throws {
|
||||
// Create two partitions
|
||||
helper.connect("Alice", "Bob")
|
||||
helper.connect("Charlie", "David")
|
||||
func testDynamicTopologyChanges() {
|
||||
// Start with Alice -> Bob -> Charlie
|
||||
connect("Alice", "Bob")
|
||||
connect("Bob", "Charlie")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Topology changes handled")
|
||||
var phase = 1
|
||||
|
||||
// Phase 1: Test initial topology
|
||||
nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||
if phase == 1 && message.sender == "Alice" {
|
||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||
self.disconnect("Alice", "Bob")
|
||||
self.disconnect("Bob", "Charlie")
|
||||
self.connect("Alice", "Charlie")
|
||||
phase = 2
|
||||
|
||||
// Send another message
|
||||
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil)
|
||||
} else if phase == 2 && message.content == "Direct message" {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial message through relay
|
||||
// Allow relay handler to be set before first send
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
self.nodes["Alice"]!.sendMessage("Relayed message", mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testNetworkPartitionRecovery() {
|
||||
// Create two partitions
|
||||
connect("Alice", "Bob")
|
||||
connect("Charlie", "David")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
|
||||
let messagesBeforeMerge = 0
|
||||
var messagesAfterMerge = 0
|
||||
|
||||
try await confirmation("Partitions merge and communicate") { receiveMessage in
|
||||
// Monitor cross-partition messages
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||
if message.sender == "Alice" {
|
||||
messagesAfterMerge += 1
|
||||
if messagesAfterMerge == 1 {
|
||||
receiveMessage()
|
||||
}
|
||||
// Monitor cross-partition messages
|
||||
nodes["David"]!.messageDeliveryHandler = { message in
|
||||
if message.sender == "Alice" {
|
||||
messagesAfterMerge += 1
|
||||
if messagesAfterMerge == 1 {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Try to send across partition (should fail)
|
||||
helper.nodes["Alice"]!.sendMessage("Before merge")
|
||||
|
||||
// Merge partitions after delay
|
||||
try await sleep(0.05)
|
||||
// Connect partitions
|
||||
helper.connect("Bob", "Charlie")
|
||||
|
||||
// Enable relay
|
||||
helper.setupRelay("Bob", nextHops: ["Charlie"])
|
||||
helper.setupRelay("Charlie", nextHops: ["David"])
|
||||
|
||||
// Send message across merged network
|
||||
helper.nodes["Alice"]!.sendMessage("After merge")
|
||||
}
|
||||
|
||||
#expect(messagesBeforeMerge == 0)
|
||||
#expect(messagesAfterMerge == 1)
|
||||
// Try to send across partition (should fail)
|
||||
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil)
|
||||
|
||||
// Merge partitions after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
// Connect partitions
|
||||
self.connect("Bob", "Charlie")
|
||||
|
||||
// Enable relay
|
||||
self.setupRelay("Bob", nextHops: ["Charlie"])
|
||||
self.setupRelay("Charlie", nextHops: ["David"])
|
||||
|
||||
// Send message across merged network
|
||||
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertEqual(messagesBeforeMerge, 0)
|
||||
XCTAssertEqual(messagesAfterMerge, 1)
|
||||
}
|
||||
|
||||
// MARK: - Mixed Message Type Scenarios
|
||||
|
||||
@Test func mixedPublicPrivateMessages() async throws {
|
||||
helper.connectFullMesh()
|
||||
func testMixedPublicPrivateMessages() throws {
|
||||
connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
|
||||
var publicCount = 0
|
||||
var privateCount = 0
|
||||
|
||||
await confirmation("Mixed messages handled correctly") { completion in
|
||||
// Bob monitors messages
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||
privateCount += 1
|
||||
} else if !message.isPrivate {
|
||||
publicCount += 1
|
||||
}
|
||||
|
||||
if publicCount == 2 && privateCount == 1 {
|
||||
completion()
|
||||
}
|
||||
// Bob monitors messages
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||
privateCount += 1
|
||||
} else if !message.isPrivate {
|
||||
publicCount += 1
|
||||
}
|
||||
|
||||
// Alice sends mixed messages
|
||||
helper.nodes["Alice"]!.sendMessage("Public 1")
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
helper.nodes["Alice"]!.sendMessage("Public 2")
|
||||
if publicCount == 2 && privateCount == 1 {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
#expect(publicCount == 2)
|
||||
#expect(privateCount == 1)
|
||||
// Alice sends mixed messages
|
||||
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil)
|
||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
nodes["Alice"]!.sendMessage("Public 2", mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertEqual(publicCount, 2)
|
||||
XCTAssertEqual(privateCount, 1)
|
||||
}
|
||||
|
||||
@Test func encryptedAndUnencryptedMix() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
func testEncryptedAndUnencryptedMix() throws {
|
||||
connect("Alice", "Bob")
|
||||
|
||||
// Setup Noise session
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Both encrypted and plain messages work")
|
||||
var plainCount = 0
|
||||
var encryptedCount = 0
|
||||
|
||||
try await confirmation("Both encrypted and plain messages work") { completion in
|
||||
// Plain path: send public message and count at Bob
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Plain message" {
|
||||
plainCount += 1
|
||||
}
|
||||
if plainCount == 1 && encryptedCount == 1 {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypted path: use NoiseSessionManager explicitly
|
||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
||||
|
||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
|
||||
data == plaintext {
|
||||
encryptedCount = 1
|
||||
if plainCount == 1 {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
helper.nodes["Alice"]!.sendMessage("Plain message")
|
||||
// Deliver encrypted packet directly
|
||||
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
// Setup handlers
|
||||
// Plain path: send public message and count at Bob
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Plain message" { plainCount += 1 }
|
||||
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() }
|
||||
}
|
||||
|
||||
// Encrypted path: use NoiseSessionManager explicitly
|
||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||
let ciphertext = try noiseManagers["Alice"]!.encrypt(plaintext, for: TestConstants.testPeerID2)
|
||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? self.noiseManagers["Bob"]!.decrypt(ciphertext, from: TestConstants.testPeerID1),
|
||||
data == plaintext {
|
||||
encryptedCount = 1
|
||||
if plainCount == 1 { expectation.fulfill() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes["Alice"]!.sendMessage("Plain message", mentions: [], to: nil)
|
||||
// Deliver encrypted packet directly
|
||||
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Network Resilience Tests
|
||||
|
||||
@Test func messageDeliveryUnderChurn() async throws {
|
||||
func testMessageDeliveryUnderChurn() {
|
||||
// Start with stable network
|
||||
helper.connectFullMesh()
|
||||
connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
|
||||
var receivedMessages = Set<String>()
|
||||
let totalMessages = 10
|
||||
|
||||
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
||||
// David tracks received messages
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||
completion()
|
||||
}
|
||||
|
||||
// Send messages while churning network
|
||||
for i in 0..<totalMessages {
|
||||
helper.nodes["Alice"]!.sendMessage("Message \(i)")
|
||||
|
||||
// Simulate churn
|
||||
if i % 3 == 0 {
|
||||
// Disconnect and reconnect random connection
|
||||
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||
let randomPair = pairs.randomElement()!
|
||||
helper.disconnect(randomPair.0, randomPair.1)
|
||||
try await sleep(0.01)
|
||||
helper.connect(randomPair.0, randomPair.1)
|
||||
}
|
||||
// David tracks received messages
|
||||
nodes["David"]!.messageDeliveryHandler = { message in
|
||||
receivedMessages.insert(message.content)
|
||||
if receivedMessages.count == totalMessages {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func peerPresenceTrackingAndReconnection() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
await confirmation("Delivery after reconnection") { delivered in
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After reconnect" {
|
||||
delivered()
|
||||
// Send messages while churning network
|
||||
for i in 0..<totalMessages {
|
||||
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||
|
||||
// Simulate churn
|
||||
if i % 3 == 0 {
|
||||
// Disconnect and reconnect random connection
|
||||
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||
let randomPair = pairs.randomElement()!
|
||||
disconnect(randomPair.0, randomPair.1)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.connect(randomPair.0, randomPair.1)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate disconnect (out of range)
|
||||
helper.disconnect("Alice", "Bob")
|
||||
// Reconnect
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
// Send after reconnection
|
||||
helper.nodes["Alice"]!.sendMessage("After reconnect")
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(receivedMessages.count, totalMessages)
|
||||
}
|
||||
|
||||
@Test func encryptedMessageAfterPeerRestart() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
func testPeerPresenceTrackingAndReconnection() {
|
||||
// Test that after disconnect/reconnect, message delivery resumes
|
||||
connect("Alice", "Bob")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Delivery after reconnection")
|
||||
var delivered = false
|
||||
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After reconnect" && !delivered {
|
||||
delivered = true
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate disconnect (out of range)
|
||||
disconnect("Alice", "Bob")
|
||||
// Reconnect
|
||||
connect("Alice", "Bob")
|
||||
|
||||
// Send after reconnection
|
||||
nodes["Alice"]!.sendMessage("After reconnect", mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertTrue(delivered)
|
||||
}
|
||||
|
||||
func testEncryptedMessageAfterPeerRestart() {
|
||||
// Test that encrypted messages work after one peer restarts
|
||||
connect("Alice", "Bob")
|
||||
do {
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
} catch {
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
XCTFail("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
|
||||
// Exchange an encrypted message
|
||||
await confirmation("First message received") { received in
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Before restart" && message.isPrivate {
|
||||
received()
|
||||
}
|
||||
let firstExpectation = XCTestExpectation(description: "First message received")
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Before restart" && message.isPrivate {
|
||||
firstExpectation.fulfill()
|
||||
}
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
|
||||
nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout)
|
||||
|
||||
// Simulate Bob restart by recreating his Noise manager
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
||||
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1)
|
||||
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)!
|
||||
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)!
|
||||
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3)
|
||||
} catch {
|
||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||
XCTFail("Failed to re-establish Noise session after restart: \(error)")
|
||||
}
|
||||
|
||||
// Now messages should work again - simulate encrypted packet
|
||||
await confirmation("Message after restart received") { received in
|
||||
helper.nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After restart success" && message.isPrivate {
|
||||
received()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let plaintext = "After restart success".data(using: .utf8)!
|
||||
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID),
|
||||
String(data: data, encoding: .utf8) == "After restart success" {
|
||||
received()
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.nodes["Alice"]!.simulateIncomingPacket(packet)
|
||||
} catch {
|
||||
Issue.record("Encryption after restart failed: \(error)")
|
||||
// Now messages should work again
|
||||
let secondExpectation = XCTestExpectation(description: "Message after restart received")
|
||||
nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After restart success" && message.isPrivate {
|
||||
secondExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate encrypted message using managers
|
||||
do {
|
||||
let plaintext = "After restart success".data(using: .utf8)!
|
||||
let ciphertext = try noiseManagers["Bob"]!.encrypt(plaintext, for: TestConstants.testPeerID1)
|
||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? self.noiseManagers["Alice"]!.decrypt(pkt.payload, from: TestConstants.testPeerID2),
|
||||
String(data: data, encoding: .utf8) == "After restart success" {
|
||||
secondExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes["Alice"]!.simulateIncomingPacket(packet)
|
||||
} catch {
|
||||
XCTFail("Encryption after restart failed: \(error)")
|
||||
}
|
||||
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
@Test func largeScaleNetwork() async throws {
|
||||
func testLargeScaleNetwork() {
|
||||
// Create larger network
|
||||
for i in 5...10 {
|
||||
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
||||
createNode("Node\(i)", peerID: "PEER\(i)")
|
||||
}
|
||||
|
||||
// Connect in ring topology with cross-connections
|
||||
let allNodes = Array(helper.nodes.keys).sorted()
|
||||
let allNodes = Array(nodes.keys).sorted()
|
||||
for i in 0..<allNodes.count {
|
||||
// Ring connection
|
||||
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||
connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||
|
||||
// Cross connection
|
||||
if i + 3 < allNodes.count {
|
||||
helper.connect(allNodes[i], allNodes[i + 3])
|
||||
connect(allNodes[i], allNodes[i + 3])
|
||||
}
|
||||
}
|
||||
|
||||
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
|
||||
// All nodes except Alice listen
|
||||
for (name, node) in helper.nodes where name != "Alice" {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == "Broadcast test" {
|
||||
nodeReaced()
|
||||
let expectation = XCTestExpectation(description: "Large network handles broadcast")
|
||||
var nodesReached = Set<String>()
|
||||
|
||||
// All nodes except Alice listen
|
||||
for (name, node) in nodes where name != "Alice" {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == "Broadcast test" {
|
||||
nodesReached.insert(name)
|
||||
if nodesReached.count == self.nodes.count - 1 {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
helper.nodes["Alice"]!.sendMessage("Broadcast test")
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
nodes["Alice"]!.sendMessage("Broadcast test", mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(nodesReached.count, nodes.count - 1)
|
||||
}
|
||||
|
||||
// MARK: - Stress Tests
|
||||
|
||||
@Test func highLoadScenario() async throws {
|
||||
helper.connectFullMesh()
|
||||
func testHighLoadScenario() {
|
||||
connectFullMesh()
|
||||
|
||||
let messagesPerNode = 25
|
||||
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
|
||||
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1)
|
||||
var receivedTotal = 0
|
||||
let expectation = XCTestExpectation(description: "High load handled")
|
||||
|
||||
await confirmation("High load handled", expectedCount: expectedTotal) { received in
|
||||
// Each node tracks messages
|
||||
for (_, node) in helper.nodes {
|
||||
node.messageDeliveryHandler = { _ in
|
||||
received()
|
||||
// Each node tracks messages
|
||||
for (_, node) in nodes {
|
||||
node.messageDeliveryHandler = { _ in
|
||||
receivedTotal += 1
|
||||
if receivedTotal >= (expectedTotal - 2) {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// All nodes send many messages simultaneously
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for (name, node) in helper.nodes {
|
||||
group.addTask {
|
||||
for i in 0..<messagesPerNode {
|
||||
node.sendMessage("\(name) message \(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
}
|
||||
|
||||
// All nodes send many messages simultaneously
|
||||
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in
|
||||
let nodeName = Array(nodes.keys).sorted()[index]
|
||||
for i in 0..<messagesPerNode {
|
||||
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil)
|
||||
}
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2)
|
||||
}
|
||||
|
||||
@Test func mixedTrafficPatterns() async throws {
|
||||
helper.connectFullMesh()
|
||||
func testMixedTrafficPatterns() {
|
||||
connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "Mixed traffic handled")
|
||||
var metrics = [
|
||||
"public": 0,
|
||||
"private": 0,
|
||||
@@ -379,7 +434,7 @@ struct IntegrationTests {
|
||||
]
|
||||
|
||||
// Setup complex handlers
|
||||
for (name, node) in helper.nodes {
|
||||
for (name, node) in nodes {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.isPrivate {
|
||||
metrics["private"]! += 1
|
||||
@@ -398,119 +453,222 @@ struct IntegrationTests {
|
||||
}
|
||||
|
||||
// Generate mixed traffic
|
||||
helper.nodes["Alice"]!.sendMessage("Public broadcast")
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
|
||||
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil)
|
||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil)
|
||||
|
||||
// Disconnect to force relay
|
||||
helper.disconnect("Alice", "David")
|
||||
helper.nodes["Alice"]!.sendMessage("Needs relay to David")
|
||||
disconnect("Alice", "David")
|
||||
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil)
|
||||
|
||||
#expect(metrics["public", default: 0] > 0)
|
||||
#expect(metrics["private", default: 0] > 0)
|
||||
#expect(metrics["mentions", default: 0] > 0)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
XCTAssertGreaterThan(metrics["public"]!, 0)
|
||||
XCTAssertGreaterThan(metrics["private"]!, 0)
|
||||
XCTAssertGreaterThan(metrics["mentions"]!, 0)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Security Integration Tests
|
||||
// Replacement for the legacy NACK test: verifies that after a
|
||||
// decryption failure, peers can rehandshake via NoiseSessionManager
|
||||
// and resume secure communication.
|
||||
@Test func rehandshakeAfterDecryptionFailure() throws {
|
||||
func testRehandshakeAfterDecryptionFailure() throws {
|
||||
// Alice <-> Bob connected
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
connect("Alice", "Bob")
|
||||
|
||||
// Establish initial Noise session
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
|
||||
guard let aliceManager = helper.noiseManagers["Alice"],
|
||||
let bobManager = helper.noiseManagers["Bob"],
|
||||
let alicePeerID = helper.nodes["Alice"]?.peerID,
|
||||
let bobPeerID = helper.nodes["Bob"]?.peerID
|
||||
else {
|
||||
Issue.record("Missing managers or peer IDs")
|
||||
return
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
|
||||
guard let aliceManager = noiseManagers["Alice"],
|
||||
let bobManager = noiseManagers["Bob"],
|
||||
let alicePeerID = nodes["Alice"]?.peerID,
|
||||
let bobPeerID = nodes["Bob"]?.peerID else {
|
||||
return XCTFail("Missing managers or peer IDs")
|
||||
}
|
||||
|
||||
|
||||
// Baseline: encrypt from Alice, decrypt at Bob
|
||||
let plaintext1 = Data("hello-secure".utf8)
|
||||
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
|
||||
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
|
||||
#expect(decrypted1 == plaintext1)
|
||||
|
||||
XCTAssertEqual(decrypted1, plaintext1)
|
||||
|
||||
// Simulate decryption failure by corrupting ciphertext
|
||||
let corrupted = encrypted1.prefix(15)
|
||||
#expect(throws: NoiseError.invalidCiphertext) {
|
||||
var corrupted = encrypted1
|
||||
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF }
|
||||
do {
|
||||
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
|
||||
XCTFail("Corrupted ciphertext should not decrypt")
|
||||
} catch {
|
||||
// Expected: treat as session desync and rehandshake
|
||||
}
|
||||
|
||||
|
||||
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
|
||||
bobManager.removeSession(for: alicePeerID)
|
||||
try helper.establishNoiseSession("Bob", "Alice")
|
||||
|
||||
try establishNoiseSession("Bob", "Alice")
|
||||
|
||||
// After rehandshake, encryption/decryption works again
|
||||
let plaintext2 = Data("hello-again".utf8)
|
||||
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
|
||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
|
||||
#expect(decrypted2 == plaintext2)
|
||||
XCTAssertEqual(decrypted2, plaintext2)
|
||||
}
|
||||
|
||||
|
||||
@Test func endToEndSecurityScenario() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
||||
func testEndToEndSecurityScenario() throws {
|
||||
connect("Alice", "Bob")
|
||||
connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
||||
|
||||
// Establish secure session between Alice and Bob only
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
|
||||
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
|
||||
|
||||
// Setup encryption at Alice
|
||||
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate && packet.recipientID != nil {
|
||||
// Encrypt private messages
|
||||
if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
|
||||
let encPacket = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
}
|
||||
let expectation = XCTestExpectation(description: "Secure communication maintained")
|
||||
var bobDecrypted = false
|
||||
var charlieIntercepted = false
|
||||
|
||||
// Setup encryption at Alice
|
||||
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate && packet.recipientID != nil {
|
||||
// Encrypt private messages
|
||||
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
||||
let encPacket = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
self.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
}
|
||||
}
|
||||
|
||||
// Bob can decrypt
|
||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
receivedPacket()
|
||||
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
|
||||
#expect(BitchatMessage(decrypted)?.content == "Secret message")
|
||||
} else {
|
||||
Issue.record("Bob was unable to decrypt the message")
|
||||
}
|
||||
|
||||
// Relay encrypted packet to Charlie
|
||||
helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
||||
}
|
||||
|
||||
// Bob can decrypt
|
||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
||||
let message = BitchatMessage(decrypted) {
|
||||
bobDecrypted = message.content == "Secret message"
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
// Relay encrypted packet to Charlie
|
||||
self.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Charlie cannot decrypt
|
||||
nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
charlieIntercepted = true
|
||||
// Try to decrypt (should fail)
|
||||
do {
|
||||
_ = try self.noiseManagers["Charlie"]?.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
||||
XCTFail("Charlie should not be able to decrypt")
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
// Charlie cannot decrypt
|
||||
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
receivedPacket()
|
||||
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
|
||||
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send encrypted private message
|
||||
nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertTrue(bobDecrypted)
|
||||
XCTAssertTrue(charlieIntercepted)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createNode(_ name: String, peerID: PeerID) {
|
||||
let node = MockBLEService()
|
||||
node.myPeerID = peerID
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
// Create Noise manager
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||
}
|
||||
|
||||
private func connect(_ node1: String, _ node2: String) {
|
||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
||||
n1.simulateConnectedPeer(n2.peerID)
|
||||
n2.simulateConnectedPeer(n1.peerID)
|
||||
}
|
||||
|
||||
private func disconnect(_ node1: String, _ node2: String) {
|
||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
||||
n1.simulateDisconnectedPeer(n2.peerID)
|
||||
n2.simulateDisconnectedPeer(n1.peerID)
|
||||
}
|
||||
|
||||
private func connectFullMesh() {
|
||||
let nodeNames = Array(nodes.keys)
|
||||
for i in 0..<nodeNames.count {
|
||||
for j in i+1..<nodeNames.count {
|
||||
connect(nodeNames[i], nodeNames[j])
|
||||
}
|
||||
|
||||
// Send encrypted private message
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
}
|
||||
|
||||
private func setupRelay(_ nodeName: String, nextHops: [String]) {
|
||||
guard let node = nodes[nodeName] else { return }
|
||||
|
||||
node.packetDeliveryHandler = { packet in
|
||||
guard packet.ttl > 1 else { return }
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
)
|
||||
|
||||
for hop in nextHops {
|
||||
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
||||
guard let manager1 = noiseManagers[node1],
|
||||
let manager2 = noiseManagers[node2],
|
||||
let peer1ID = nodes[node1]?.peerID,
|
||||
let peer2ID = nodes[node2]?.peerID else { return }
|
||||
|
||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
//
|
||||
// TestNetworkHelper.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Extracted shared, mutable integration state for nodes and noise sessions.
|
||||
// Keeps test containers nonmutating (Swift Testing-friendly).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
final class TestNetworkHelper {
|
||||
// Public, read-only views for tests; mutation only through methods
|
||||
var nodes: [String: MockBLEService] = [:]
|
||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||
let mockKeychain = MockKeychain()
|
||||
private let bus = MockBLEBus(autoFloodEnabled: true)
|
||||
|
||||
// MARK: - Node/Manager management
|
||||
|
||||
@discardableResult
|
||||
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
|
||||
let node = MockBLEService(bus: bus)
|
||||
node.myPeerID = peerID
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
// Create/replace Noise manager for this node
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||
return node
|
||||
}
|
||||
|
||||
func getNode(_ name: String) -> MockBLEService? {
|
||||
nodes[name]
|
||||
}
|
||||
|
||||
func getManager(_ name: String) -> NoiseSessionManager? {
|
||||
noiseManagers[name]
|
||||
}
|
||||
|
||||
// MARK: - Topology
|
||||
|
||||
func connect(_ a: String, _ b: String) {
|
||||
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
|
||||
n1.simulateConnectedPeer(n2.peerID)
|
||||
n2.simulateConnectedPeer(n1.peerID)
|
||||
}
|
||||
|
||||
func disconnect(_ a: String, _ b: String) {
|
||||
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
|
||||
n1.simulateDisconnectedPeer(n2.peerID)
|
||||
n2.simulateDisconnectedPeer(n1.peerID)
|
||||
}
|
||||
|
||||
func connectFullMesh() {
|
||||
let names = Array(nodes.keys)
|
||||
for i in 0..<names.count {
|
||||
for j in (i+1)..<names.count {
|
||||
connect(names[i], names[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Relay
|
||||
|
||||
func setupRelay(_ nodeName: String, nextHops: [String]) {
|
||||
guard let node = nodes[nodeName] else { return }
|
||||
node.packetDeliveryHandler = { [weak self] packet in
|
||||
guard let self else { return }
|
||||
guard packet.ttl > 1 else { return }
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
)
|
||||
|
||||
for hop in nextHops {
|
||||
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise sessions
|
||||
|
||||
func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
||||
guard let manager1 = noiseManagers[node1],
|
||||
let manager2 = noiseManagers[node2],
|
||||
let peer1ID = nodes[node1]?.peerID,
|
||||
let peer2ID = nodes[node2]?.peerID else { return }
|
||||
|
||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct LocationChannelsTests {
|
||||
@Test func geohashEncoderPrecisionMapping() {
|
||||
final class LocationChannelsTests: XCTestCase {
|
||||
func testGeohashEncoderPrecisionMapping() {
|
||||
// Sanity: known coords (Statue of Liberty approx)
|
||||
let lat = 40.6892
|
||||
let lon = -74.0445
|
||||
@@ -13,35 +12,35 @@ struct LocationChannelsTests {
|
||||
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
||||
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
||||
|
||||
#expect(block.count == 7)
|
||||
#expect(neighborhood.count == 6)
|
||||
#expect(city.count == 5)
|
||||
#expect(region.count == 4)
|
||||
#expect(country.count == 2)
|
||||
XCTAssertEqual(block.count, 7)
|
||||
XCTAssertEqual(neighborhood.count, 6)
|
||||
XCTAssertEqual(city.count, 5)
|
||||
XCTAssertEqual(region.count, 4)
|
||||
XCTAssertEqual(country.count, 2)
|
||||
|
||||
// All prefixes must match progressively
|
||||
#expect(block.hasPrefix(neighborhood))
|
||||
#expect(neighborhood.hasPrefix(city))
|
||||
#expect(city.hasPrefix(region))
|
||||
#expect(region.hasPrefix(country))
|
||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
||||
XCTAssertTrue(city.hasPrefix(region))
|
||||
XCTAssertTrue(region.hasPrefix(country))
|
||||
}
|
||||
|
||||
@Test func nostrGeohashFilterEncoding() throws {
|
||||
func testNostrGeohashFilterEncoding() throws {
|
||||
let gh = "u4pruy"
|
||||
let filter = NostrFilter.geohashEphemeral(gh)
|
||||
let data = try JSONEncoder().encode(filter)
|
||||
let json = String(data: data, encoding: .utf8) ?? ""
|
||||
// Expect kinds includes 20000 and tag filter '#g':[gh]
|
||||
#expect(json.contains("20000"))
|
||||
#expect(json.contains("\"#g\":[\"\(gh)\"]"))
|
||||
XCTAssertTrue(json.contains("20000"))
|
||||
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]"))
|
||||
}
|
||||
|
||||
@Test func perGeohashIdentityDeterministic() throws {
|
||||
func testPerGeohashIdentityDeterministic() throws {
|
||||
// Derive twice for same geohash; should be identical
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let gh = "u4pruy"
|
||||
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||
#expect(id1.publicKeyHex == id2.publicKeyHex)
|
||||
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
struct LocationNotesManagerTests {
|
||||
final class LocationNotesManagerTests: XCTestCase {
|
||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||
// var subscribeCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
@@ -48,15 +47,15 @@ struct LocationNotesManagerTests {
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
||||
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
||||
var relaysCaptured: [String] = []
|
||||
var storedHandler: ((NostrEvent) -> Void)?
|
||||
var storedEOSE: (() -> Void)?
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { filter, id, relays, handler, eose in
|
||||
#expect(filter.kinds == [1])
|
||||
#expect(!id.isEmpty)
|
||||
XCTAssertEqual(filter.kinds, [1])
|
||||
XCTAssertFalse(id.isEmpty)
|
||||
relaysCaptured = relays
|
||||
storedHandler = handler
|
||||
storedEOSE = eose
|
||||
@@ -68,8 +67,8 @@ struct LocationNotesManagerTests {
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
#expect(relaysCaptured == ["wss://relay.one"])
|
||||
#expect(manager.state == .loading)
|
||||
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||
XCTAssertEqual(manager.state, .loading)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub",
|
||||
@@ -82,12 +81,70 @@ struct LocationNotesManagerTests {
|
||||
storedHandler?(event)
|
||||
storedEOSE?()
|
||||
|
||||
#expect(manager.state == .ready)
|
||||
#expect(manager.notes.count == 1)
|
||||
#expect(manager.notes.first?.content == "hi")
|
||||
XCTAssertEqual(manager.state, .ready)
|
||||
XCTAssertEqual(manager.notes.count, 1)
|
||||
XCTAssertEqual(manager.notes.first?.content, "hi")
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case shouldNotDerive
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LocationNotesCounterTests: XCTestCase {
|
||||
func testSubscribeWithoutRelaysMarksUnavailable() {
|
||||
var subscribeCalled = false
|
||||
let deps = LocationNotesCounterDependencies(
|
||||
relayLookup: { _, _ in [] },
|
||||
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
||||
unsubscribe: { _ in }
|
||||
)
|
||||
|
||||
let counter = LocationNotesCounter(testDependencies: deps)
|
||||
counter.subscribe(geohash: "u4pruydq")
|
||||
|
||||
XCTAssertFalse(subscribeCalled)
|
||||
XCTAssertFalse(counter.relayAvailable)
|
||||
XCTAssertTrue(counter.initialLoadComplete)
|
||||
XCTAssertEqual(counter.count, 0)
|
||||
}
|
||||
|
||||
func testSubscribeCountsUniqueNotes() {
|
||||
var storedHandler: ((NostrEvent) -> Void)?
|
||||
var storedEOSE: (() -> Void)?
|
||||
let deps = LocationNotesCounterDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.geo"] },
|
||||
subscribe: { filter, id, relays, handler, eose in
|
||||
XCTAssertEqual(relays, ["wss://relay.geo"])
|
||||
XCTAssertEqual(filter.kinds, [1])
|
||||
XCTAssertFalse(id.isEmpty)
|
||||
storedHandler = handler
|
||||
storedEOSE = eose
|
||||
},
|
||||
unsubscribe: { _ in }
|
||||
)
|
||||
|
||||
let counter = LocationNotesCounter(testDependencies: deps)
|
||||
counter.subscribe(geohash: "u4pruydq")
|
||||
|
||||
var first = NostrEvent(
|
||||
pubkey: "pub",
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "a"
|
||||
)
|
||||
first.id = "eventA"
|
||||
storedHandler?(first)
|
||||
|
||||
let duplicate = first
|
||||
storedHandler?(duplicate)
|
||||
|
||||
storedEOSE?()
|
||||
|
||||
XCTAssertTrue(counter.relayAvailable)
|
||||
XCTAssertEqual(counter.count, 1)
|
||||
XCTAssertTrue(counter.initialLoadComplete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// MimeTypeTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - MimeType Mapping and Signature Tests
|
||||
|
||||
struct MimeTypeTests {
|
||||
|
||||
// MARK: MIME → Enum Parsing + Default Extension
|
||||
@Test(arguments: [
|
||||
("image/jpeg", MimeType.jpeg, "jpg"),
|
||||
("image/jpg", MimeType.jpeg, "jpg"),
|
||||
("image/png", MimeType.png, "png"),
|
||||
("image/gif", MimeType.gif, "gif"),
|
||||
("image/webp", MimeType.webp, "webp"),
|
||||
("audio/mp4", MimeType.mp4Audio, "m4a"),
|
||||
("audio/m4a", MimeType.m4a, "m4a"),
|
||||
("audio/aac", MimeType.aac, "m4a"),
|
||||
("audio/mpeg", MimeType.mpeg, "mp3"),
|
||||
("audio/mp3", MimeType.mp3, "mp3"),
|
||||
("audio/wav", MimeType.wav, "wav"),
|
||||
("audio/x-wav", MimeType.xWav, "wav"),
|
||||
("audio/ogg", MimeType.ogg, "ogg"),
|
||||
("application/pdf", MimeType.pdf, "pdf"),
|
||||
("application/octet-stream", MimeType.octetStream, "bin")
|
||||
])
|
||||
func mimeTypeParsingAndExtensions(
|
||||
mimeString: String,
|
||||
expectedType: MimeType,
|
||||
expectedExt: String
|
||||
) throws {
|
||||
guard let mime = MimeType(mimeString) else {
|
||||
Issue.record("Failed to parse \(mimeString)")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
|
||||
#expect(mime.mimeString == expectedType.mimeString)
|
||||
#expect(mime.defaultExtension == expectedExt)
|
||||
#expect(mime.isAllowed)
|
||||
}
|
||||
|
||||
// MARK: - File Signature Validation
|
||||
@Test(arguments: [
|
||||
// === Image types ===
|
||||
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
|
||||
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
|
||||
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
|
||||
|
||||
// === Audio types ===
|
||||
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
|
||||
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
|
||||
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
|
||||
|
||||
// === Application types ===
|
||||
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
|
||||
])
|
||||
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
|
||||
let data = Data(bytes)
|
||||
#expect(mime.matches(data: data),
|
||||
"Expected \(mime.mimeString) to match its signature")
|
||||
}
|
||||
|
||||
// MARK: - Negative Tests
|
||||
@Test func invalidDataDoesNotMatch() throws {
|
||||
let badData = Data(repeating: 0x00, count: 16)
|
||||
for mime in MimeType.allCases where mime != .octetStream {
|
||||
#expect(!mime.matches(data: badData),
|
||||
"Unexpectedly matched \(mime.mimeString) with zeroed data")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Octet-stream (generic binary)
|
||||
@Test func octetStreamAlwaysMatches() throws {
|
||||
let randomData = Data([0x00, 0x11, 0x22, 0x33])
|
||||
#expect(MimeType.octetStream.matches(data: randomData),
|
||||
"application/octet-stream should always be considered valid")
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// MockBLEBus.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
final class MockBLEBus {
|
||||
private var registry: [PeerID: MockBLEService] = [:]
|
||||
private var adjacency: [PeerID: Set<PeerID>] = [:]
|
||||
|
||||
// Enable automatic flooding for public messages in integration tests only
|
||||
let autoFloodEnabled: Bool
|
||||
|
||||
init(autoFloodEnabled: Bool = false) {
|
||||
self.autoFloodEnabled = autoFloodEnabled
|
||||
}
|
||||
|
||||
func register(_ service: MockBLEService, for peerID: PeerID) {
|
||||
registry[peerID] = service
|
||||
if adjacency[peerID] == nil { adjacency[peerID] = [] }
|
||||
}
|
||||
|
||||
func connect(_ a: PeerID, _ b: PeerID) {
|
||||
var setA = adjacency[a] ?? []
|
||||
setA.insert(b)
|
||||
adjacency[a] = setA
|
||||
var setB = adjacency[b] ?? []
|
||||
setB.insert(a)
|
||||
adjacency[b] = setB
|
||||
}
|
||||
|
||||
func disconnect(_ a: PeerID, _ b: PeerID) {
|
||||
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
||||
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
||||
}
|
||||
|
||||
func neighbors(of peerID: PeerID) -> [MockBLEService] {
|
||||
let ids = adjacency[peerID] ?? []
|
||||
let result = ids.compactMap { registry[$0] }
|
||||
return result
|
||||
}
|
||||
|
||||
func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool {
|
||||
let res = adjacency[a]?.contains(b) ?? false
|
||||
return res
|
||||
}
|
||||
|
||||
func service(for peerID: PeerID) -> MockBLEService? {
|
||||
let svc = registry[peerID]
|
||||
return svc
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,13 @@ import CoreBluetooth
|
||||
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
|
||||
/// relays when needed.
|
||||
final class MockBLEService: NSObject {
|
||||
private let bus: MockBLEBus
|
||||
// Enable automatic flooding for public messages in integration tests only
|
||||
static var autoFloodEnabled: Bool = false
|
||||
|
||||
// MARK: - Properties matching BLEService
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
var myPeerID = PeerID(str: "MOCK1234")
|
||||
var myPeerID: PeerID = "MOCK1234"
|
||||
var myNickname: String = "MockUser"
|
||||
|
||||
private let mockKeychain = MockKeychain()
|
||||
@@ -59,8 +60,8 @@ final class MockBLEService: NSObject {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(bus: MockBLEBus) {
|
||||
self.bus = bus
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Methods matching BLEService
|
||||
@@ -70,15 +71,42 @@ final class MockBLEService: NSObject {
|
||||
}
|
||||
|
||||
// MARK: - In-memory test bus (for E2E/Integration)
|
||||
/// Global per-process bus for deterministic routing in tests.
|
||||
private static var registry: [PeerID: MockBLEService] = [:]
|
||||
private static var adjacency: [PeerID: Set<PeerID>] = [:]
|
||||
|
||||
/// Clears global bus state. Call from test `setUp()`.
|
||||
static func resetTestBus() {
|
||||
registry.removeAll()
|
||||
adjacency.removeAll()
|
||||
}
|
||||
|
||||
/// Registers this instance on first use.
|
||||
private func registerIfNeeded() {
|
||||
bus.register(self, for: myPeerID)
|
||||
MockBLEService.registry[myPeerID] = self
|
||||
if MockBLEService.adjacency[myPeerID] == nil { MockBLEService.adjacency[myPeerID] = [] }
|
||||
}
|
||||
|
||||
/// Returns adjacent neighbors based on the current simulated topology.
|
||||
private func neighbors() -> [MockBLEService] {
|
||||
bus.neighbors(of: myPeerID)
|
||||
guard let ids = MockBLEService.adjacency[myPeerID] else { return [] }
|
||||
return ids.compactMap { MockBLEService.registry[$0] }
|
||||
}
|
||||
|
||||
/// Adds an undirected edge between two peerIDs.
|
||||
private static func connectPeers(_ a: PeerID, _ b: PeerID) {
|
||||
var setA = adjacency[a] ?? []
|
||||
setA.insert(b)
|
||||
adjacency[a] = setA
|
||||
var setB = adjacency[b] ?? []
|
||||
setB.insert(a)
|
||||
adjacency[b] = setB
|
||||
}
|
||||
|
||||
/// Removes an undirected edge between two peerIDs.
|
||||
private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
|
||||
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
||||
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
||||
}
|
||||
|
||||
func startServices() {
|
||||
@@ -145,7 +173,7 @@ final class MockBLEService: NSObject {
|
||||
// Surface raw packet to tests that intercept/relay/encrypt
|
||||
packetDeliveryHandler?(packet)
|
||||
|
||||
// Deliver public messages to adjacent peers via bus
|
||||
// Deliver public messages to adjacent peers via test bus
|
||||
if recipientID == nil {
|
||||
for neighbor in neighbors() {
|
||||
neighbor.simulateIncomingPacket(packet)
|
||||
@@ -199,13 +227,20 @@ final class MockBLEService: NSObject {
|
||||
packetDeliveryHandler?(packet)
|
||||
|
||||
// If directly connected to recipient, deliver only to them.
|
||||
if bus.isDirectNeighbor(myPeerID, recipientPeerID),
|
||||
let target = bus.service(for: recipientPeerID) {
|
||||
if let neighbors = MockBLEService.adjacency[myPeerID], neighbors.contains(recipientPeerID),
|
||||
let target = MockBLEService.registry[recipientPeerID] {
|
||||
target.simulateIncomingPacket(packet)
|
||||
} else {
|
||||
// Not directly connected: deliver to neighbors for relay
|
||||
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||
neighbor.simulateIncomingPacket(packet)
|
||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
||||
if let target = MockBLEService.registry[recipientPeerID] {
|
||||
target.simulateIncomingPacket(packet)
|
||||
}
|
||||
if let neighbors = MockBLEService.adjacency[myPeerID] {
|
||||
for peer in neighbors where peer != recipientPeerID {
|
||||
if let neighbor = MockBLEService.registry[peer] {
|
||||
neighbor.simulateIncomingPacket(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,14 +287,14 @@ final class MockBLEService: NSObject {
|
||||
|
||||
func simulateConnectedPeer(_ peerID: PeerID) {
|
||||
registerIfNeeded()
|
||||
bus.connect(myPeerID, peerID)
|
||||
MockBLEService.connectPeers(myPeerID, peerID)
|
||||
connectedPeers.insert(peerID)
|
||||
delegate?.didConnectToPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
}
|
||||
|
||||
func simulateDisconnectedPeer(_ peerID: PeerID) {
|
||||
bus.disconnect(myPeerID, peerID)
|
||||
MockBLEService.disconnectPeers(myPeerID, peerID)
|
||||
connectedPeers.remove(peerID)
|
||||
delegate?.didDisconnectFromPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
@@ -292,7 +327,7 @@ final class MockBLEService: NSObject {
|
||||
// When enabled, propagate a public broadcast across the entire connected
|
||||
// component regardless of the original TTL to better emulate large-network
|
||||
// broadcast expectations. De-duplication via seenMessageIDs prevents loops.
|
||||
if bus.autoFloodEnabled,
|
||||
if MockBLEService.autoFloodEnabled,
|
||||
packet.recipientID == nil,
|
||||
!message.isPrivate {
|
||||
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
||||
@@ -326,8 +361,8 @@ typealias MockSimplifiedBluetoothService = MockBLEService
|
||||
// MARK: - Helpers
|
||||
|
||||
extension MockBLEService {
|
||||
convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) {
|
||||
self.init(bus: bus)
|
||||
convenience init(peerID: PeerID, nickname: String) {
|
||||
self.init()
|
||||
myPeerID = peerID
|
||||
mockNickname = nickname
|
||||
}
|
||||
|
||||
@@ -6,123 +6,135 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct NoiseProtocolTests {
|
||||
final class NoiseProtocolTests: XCTestCase {
|
||||
|
||||
private let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let mockKeychain = MockKeychain()
|
||||
var aliceKey: Curve25519.KeyAgreement.PrivateKey!
|
||||
var bobKey: Curve25519.KeyAgreement.PrivateKey!
|
||||
var aliceSession: NoiseSession!
|
||||
var bobSession: NoiseSession!
|
||||
private var mockKeychain: MockKeychain!
|
||||
|
||||
private let alicePeerID = PeerID(str: UUID().uuidString)
|
||||
private let bobPeerID = PeerID(str: UUID().uuidString)
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
mockKeychain = MockKeychain()
|
||||
}
|
||||
|
||||
private let aliceSession: NoiseSession
|
||||
private let bobSession: NoiseSession
|
||||
override func tearDown() {
|
||||
aliceSession = nil
|
||||
bobSession = nil
|
||||
mockKeychain = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
init() {
|
||||
// MARK: - Basic Handshake Tests
|
||||
|
||||
func testXXPatternHandshake() throws {
|
||||
// Create sessions
|
||||
aliceSession = NoiseSession(
|
||||
peerID: alicePeerID,
|
||||
peerID: TestConstants.testPeerID2,
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
bobSession = NoiseSession(
|
||||
peerID: bobPeerID,
|
||||
peerID: TestConstants.testPeerID1,
|
||||
role: .responder,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: bobKey
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Basic Handshake Tests
|
||||
|
||||
@Test func xxPatternHandshake() throws {
|
||||
|
||||
// Alice starts handshake (message 1)
|
||||
let message1 = try aliceSession.startHandshake()
|
||||
#expect(!message1.isEmpty)
|
||||
#expect(aliceSession.getState() == .handshaking)
|
||||
XCTAssertFalse(message1.isEmpty)
|
||||
XCTAssertEqual(aliceSession.getState(), .handshaking)
|
||||
|
||||
// Bob processes message 1 and creates message 2
|
||||
let message2 = try bobSession.processHandshakeMessage(message1)
|
||||
#expect(message2 != nil)
|
||||
#expect(!message2!.isEmpty)
|
||||
#expect(bobSession.getState() == .handshaking)
|
||||
XCTAssertNotNil(message2)
|
||||
XCTAssertFalse(message2!.isEmpty)
|
||||
XCTAssertEqual(bobSession.getState(), .handshaking)
|
||||
|
||||
// Alice processes message 2 and creates message 3
|
||||
let message3 = try aliceSession.processHandshakeMessage(message2!)
|
||||
#expect(message3 != nil)
|
||||
#expect(!message3!.isEmpty)
|
||||
#expect(aliceSession.getState() == .established)
|
||||
XCTAssertNotNil(message3)
|
||||
XCTAssertFalse(message3!.isEmpty)
|
||||
XCTAssertEqual(aliceSession.getState(), .established)
|
||||
|
||||
// Bob processes message 3 and completes handshake
|
||||
let finalMessage = try bobSession.processHandshakeMessage(message3!)
|
||||
#expect(finalMessage == nil) // No more messages needed
|
||||
#expect(bobSession.getState() == .established)
|
||||
XCTAssertNil(finalMessage) // No more messages needed
|
||||
XCTAssertEqual(bobSession.getState(), .established)
|
||||
|
||||
// Verify both sessions are established
|
||||
#expect(aliceSession.isEstablished())
|
||||
#expect(bobSession.isEstablished())
|
||||
XCTAssertTrue(aliceSession.isEstablished())
|
||||
XCTAssertTrue(bobSession.isEstablished())
|
||||
|
||||
// Verify they have each other's static keys
|
||||
#expect(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation)
|
||||
#expect(bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation)
|
||||
XCTAssertEqual(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation, bobKey.publicKey.rawRepresentation)
|
||||
XCTAssertEqual(bobSession.getRemoteStaticPublicKey()?.rawRepresentation, aliceKey.publicKey.rawRepresentation)
|
||||
}
|
||||
|
||||
@Test func handshakeStateValidation() throws {
|
||||
func testHandshakeStateValidation() throws {
|
||||
aliceSession = NoiseSession(
|
||||
peerID: TestConstants.testPeerID2,
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
// Cannot process message before starting handshake
|
||||
#expect(throws: NoiseSessionError.invalidState) {
|
||||
try aliceSession.processHandshakeMessage(Data())
|
||||
}
|
||||
XCTAssertThrowsError(try aliceSession.processHandshakeMessage(Data()))
|
||||
|
||||
// Start handshake
|
||||
_ = try aliceSession.startHandshake()
|
||||
|
||||
// Cannot start handshake twice
|
||||
#expect(throws: NoiseSessionError.invalidState) {
|
||||
try aliceSession.startHandshake()
|
||||
}
|
||||
XCTAssertThrowsError(try aliceSession.startHandshake())
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption Tests
|
||||
|
||||
@Test func basicEncryptionDecryption() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
func testBasicEncryptionDecryption() throws {
|
||||
// Establish sessions
|
||||
try establishSessions()
|
||||
|
||||
let plaintext = "Hello, Bob!".data(using: .utf8)!
|
||||
|
||||
// Alice encrypts
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
#expect(ciphertext != plaintext)
|
||||
#expect(ciphertext.count > plaintext.count) // Should have overhead
|
||||
XCTAssertNotEqual(ciphertext, plaintext)
|
||||
XCTAssertGreaterThan(ciphertext.count, plaintext.count) // Should have overhead
|
||||
|
||||
// Bob decrypts
|
||||
let decrypted = try bobSession.decrypt(ciphertext)
|
||||
#expect(decrypted == plaintext)
|
||||
XCTAssertEqual(decrypted, plaintext)
|
||||
}
|
||||
|
||||
@Test func bidirectionalEncryption() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
func testBidirectionalEncryption() throws {
|
||||
try establishSessions()
|
||||
|
||||
// Alice -> Bob
|
||||
let aliceMessage = "Hello from Alice".data(using: .utf8)!
|
||||
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
|
||||
let bobReceived = try bobSession.decrypt(aliceCiphertext)
|
||||
#expect(bobReceived == aliceMessage)
|
||||
XCTAssertEqual(bobReceived, aliceMessage)
|
||||
|
||||
// Bob -> Alice
|
||||
let bobMessage = "Hello from Bob".data(using: .utf8)!
|
||||
let bobCiphertext = try bobSession.encrypt(bobMessage)
|
||||
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
|
||||
#expect(aliceReceived == bobMessage)
|
||||
XCTAssertEqual(aliceReceived, bobMessage)
|
||||
}
|
||||
|
||||
@Test func largeMessageEncryption() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
func testLargeMessageEncryption() throws {
|
||||
try establishSessions()
|
||||
|
||||
// Create a large message
|
||||
let largeMessage = TestHelpers.generateRandomData(length: 100_000)
|
||||
@@ -131,78 +143,81 @@ struct NoiseProtocolTests {
|
||||
let ciphertext = try aliceSession.encrypt(largeMessage)
|
||||
let decrypted = try bobSession.decrypt(ciphertext)
|
||||
|
||||
#expect(decrypted == largeMessage)
|
||||
XCTAssertEqual(decrypted, largeMessage)
|
||||
}
|
||||
|
||||
@Test func encryptionBeforeHandshake() {
|
||||
func testEncryptionBeforeHandshake() {
|
||||
aliceSession = NoiseSession(
|
||||
peerID: TestConstants.testPeerID2,
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
let plaintext = "test".data(using: .utf8)!
|
||||
|
||||
#expect(throws: NoiseSessionError.notEstablished) {
|
||||
try aliceSession.encrypt(plaintext)
|
||||
}
|
||||
|
||||
#expect(throws: NoiseSessionError.notEstablished) {
|
||||
try aliceSession.decrypt(plaintext)
|
||||
}
|
||||
// Should throw when not established
|
||||
XCTAssertThrowsError(try aliceSession.encrypt(plaintext))
|
||||
XCTAssertThrowsError(try aliceSession.decrypt(plaintext))
|
||||
}
|
||||
|
||||
// MARK: - Session Manager Tests
|
||||
|
||||
@Test func sessionManagerBasicOperations() throws {
|
||||
func testSessionManagerBasicOperations() throws {
|
||||
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
|
||||
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||
|
||||
_ = try manager.initiateHandshake(with: alicePeerID)
|
||||
#expect(manager.getSession(for: alicePeerID) != nil)
|
||||
|
||||
|
||||
// Create session
|
||||
let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator)
|
||||
XCTAssertNotNil(session)
|
||||
|
||||
// Get session
|
||||
let retrieved = manager.getSession(for: alicePeerID)
|
||||
#expect(retrieved != nil)
|
||||
|
||||
let retrieved = manager.getSession(for: TestConstants.testPeerID2)
|
||||
XCTAssertNotNil(retrieved)
|
||||
XCTAssertTrue(session === retrieved)
|
||||
|
||||
// Remove session
|
||||
manager.removeSession(for: alicePeerID)
|
||||
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||
manager.removeSession(for: TestConstants.testPeerID2)
|
||||
XCTAssertNil(manager.getSession(for: TestConstants.testPeerID2))
|
||||
}
|
||||
|
||||
@Test func sessionManagerHandshakeInitiation() throws {
|
||||
func testSessionManagerHandshakeInitiation() throws {
|
||||
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
|
||||
// Initiate handshake
|
||||
let handshakeData = try manager.initiateHandshake(with: alicePeerID)
|
||||
#expect(!handshakeData.isEmpty)
|
||||
let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2)
|
||||
XCTAssertFalse(handshakeData.isEmpty)
|
||||
|
||||
// Session should exist
|
||||
let session = manager.getSession(for: alicePeerID)
|
||||
#expect(session != nil)
|
||||
#expect(session?.getState() == .handshaking)
|
||||
let session = manager.getSession(for: TestConstants.testPeerID2)
|
||||
XCTAssertNotNil(session)
|
||||
XCTAssertEqual(session?.getState(), .handshaking)
|
||||
}
|
||||
|
||||
@Test func sessionManagerIncomingHandshake() throws {
|
||||
func testSessionManagerIncomingHandshake() throws {
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
// Alice initiates
|
||||
let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
||||
|
||||
// Bob responds
|
||||
let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
|
||||
#expect(message2 != nil)
|
||||
let message2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message1)
|
||||
XCTAssertNotNil(message2)
|
||||
|
||||
// Continue handshake
|
||||
let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!)
|
||||
#expect(message3 != nil)
|
||||
let message3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: message2!)
|
||||
XCTAssertNotNil(message3)
|
||||
|
||||
// Complete handshake
|
||||
let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!)
|
||||
#expect(finalMessage == nil)
|
||||
let finalMessage = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message3!)
|
||||
XCTAssertNil(finalMessage)
|
||||
|
||||
// Both should have established sessions
|
||||
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
|
||||
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
|
||||
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false)
|
||||
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false)
|
||||
}
|
||||
|
||||
@Test func sessionManagerEncryptionDecryption() throws {
|
||||
func testSessionManagerEncryptionDecryption() throws {
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
@@ -211,17 +226,17 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Encrypt with manager
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||
let ciphertext = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2)
|
||||
|
||||
// Decrypt with manager
|
||||
let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID)
|
||||
#expect(decrypted == plaintext)
|
||||
let decrypted = try bobManager.decrypt(ciphertext, from: TestConstants.testPeerID1)
|
||||
XCTAssertEqual(decrypted, plaintext)
|
||||
}
|
||||
|
||||
// MARK: - Security Tests
|
||||
|
||||
@Test func tamperedCiphertextDetection() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
func testTamperedCiphertextDetection() throws {
|
||||
try establishSessions()
|
||||
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
var ciphertext = try aliceSession.encrypt(plaintext)
|
||||
@@ -230,19 +245,11 @@ struct NoiseProtocolTests {
|
||||
ciphertext[ciphertext.count / 2] ^= 0xFF
|
||||
|
||||
// Decryption should fail
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try bobSession.decrypt(ciphertext)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try bobSession.decrypt(ciphertext)
|
||||
}
|
||||
}
|
||||
XCTAssertThrowsError(try bobSession.decrypt(ciphertext))
|
||||
}
|
||||
|
||||
@Test func replayPrevention() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
func testReplayPrevention() throws {
|
||||
try establishSessions()
|
||||
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
@@ -251,18 +258,16 @@ struct NoiseProtocolTests {
|
||||
_ = try bobSession.decrypt(ciphertext)
|
||||
|
||||
// Replaying the same ciphertext should fail
|
||||
#expect(throws: NoiseError.replayDetected) {
|
||||
try bobSession.decrypt(ciphertext)
|
||||
}
|
||||
XCTAssertThrowsError(try bobSession.decrypt(ciphertext))
|
||||
}
|
||||
|
||||
@Test func sessionIsolation() throws {
|
||||
func testSessionIsolation() throws {
|
||||
// Create two separate session pairs
|
||||
let aliceSession1 = NoiseSession(peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession1 = NoiseSession(peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
|
||||
let aliceSession2 = NoiseSession(peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession2 = NoiseSession(peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
|
||||
// Establish both pairs
|
||||
try performHandshake(initiator: aliceSession1, responder: bobSession1)
|
||||
@@ -273,24 +278,16 @@ struct NoiseProtocolTests {
|
||||
let ciphertext1 = try aliceSession1.encrypt(plaintext)
|
||||
|
||||
// Should not be able to decrypt with session 2
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try bobSession2.decrypt(ciphertext1)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try bobSession2.decrypt(ciphertext1)
|
||||
}
|
||||
}
|
||||
XCTAssertThrowsError(try bobSession2.decrypt(ciphertext1))
|
||||
|
||||
// But should work with correct session
|
||||
let decrypted = try bobSession1.decrypt(ciphertext1)
|
||||
#expect(decrypted == plaintext)
|
||||
XCTAssertEqual(decrypted, plaintext)
|
||||
}
|
||||
|
||||
// MARK: - Session Recovery Tests
|
||||
|
||||
@Test func peerRestartDetection() throws {
|
||||
func testPeerRestartDetection() throws {
|
||||
// Establish initial sessions
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
@@ -298,38 +295,38 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Exchange some messages to establish nonce state
|
||||
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
|
||||
_ = try bobManager.decrypt(message1, from: bobPeerID)
|
||||
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||
_ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1)
|
||||
|
||||
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
|
||||
_ = try aliceManager.decrypt(message2, from: alicePeerID)
|
||||
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1)
|
||||
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2)
|
||||
|
||||
// Simulate Bob restart by creating new manager with same key
|
||||
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
// Bob initiates new handshake after restart
|
||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1)
|
||||
|
||||
// Alice should accept the new handshake (clearing old session)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1)
|
||||
XCTAssertNotNil(newHandshake2)
|
||||
|
||||
// Complete the new handshake
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: bobPeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!)
|
||||
XCTAssertNotNil(newHandshake3)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake3!)
|
||||
|
||||
// Should be able to exchange messages with new sessions
|
||||
let testMessage = "After restart".data(using: .utf8)!
|
||||
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
|
||||
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
|
||||
#expect(decrypted == testMessage)
|
||||
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1)
|
||||
let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2)
|
||||
XCTAssertEqual(decrypted, testMessage)
|
||||
}
|
||||
|
||||
@Test func nonceDesynchronizationRecovery() throws {
|
||||
func testNonceDesynchronizationRecovery() throws {
|
||||
// Create two sessions
|
||||
let aliceSession = NoiseSession(peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession = NoiseSession(peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
|
||||
// Establish sessions
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
@@ -347,12 +344,10 @@ struct NoiseProtocolTests {
|
||||
|
||||
// With per-packet nonce carried, decryption should not throw here
|
||||
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
|
||||
#expect(throws: Never.self) {
|
||||
try bobSession.decrypt(desyncMessage)
|
||||
}
|
||||
XCTAssertNoThrow(try bobSession.decrypt(desyncMessage))
|
||||
}
|
||||
|
||||
@Test func concurrentEncryption() async throws {
|
||||
func testConcurrentEncryption() throws {
|
||||
// Test thread safety of encryption operations
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
@@ -360,35 +355,37 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
let messageCount = 100
|
||||
let expectation = XCTestExpectation(description: "All messages encrypted and decrypted")
|
||||
expectation.expectedFulfillmentCount = messageCount
|
||||
|
||||
var encryptedMessages: [Int: Data] = [:]
|
||||
// Encrypt messages sequentially to avoid nonce races in manager
|
||||
for i in 0..<messageCount {
|
||||
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
||||
let encrypted = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2)
|
||||
encryptedMessages[i] = encrypted
|
||||
}
|
||||
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
|
||||
var encryptedMessages: [Int: Data] = [:]
|
||||
// Encrypt messages sequentially to avoid nonce races in manager
|
||||
for i in 0..<messageCount {
|
||||
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
||||
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||
encryptedMessages[i] = encrypted
|
||||
}
|
||||
|
||||
// Decrypt messages sequentially to avoid triggering anti-replay with reordering
|
||||
for i in 0..<messageCount {
|
||||
do {
|
||||
guard let encrypted = encryptedMessages[i] else {
|
||||
Issue.record("Missing encrypted message \(i)")
|
||||
return
|
||||
}
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
||||
#expect(decrypted == expected)
|
||||
completion()
|
||||
} catch {
|
||||
Issue.record("Decryption failed for message \(i): \(error)")
|
||||
// Decrypt messages sequentially to avoid triggering anti-replay with reordering
|
||||
for i in 0..<messageCount {
|
||||
do {
|
||||
guard let encrypted = encryptedMessages[i] else {
|
||||
XCTFail("Missing encrypted message \(i)")
|
||||
return
|
||||
}
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
|
||||
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
||||
XCTAssertEqual(decrypted, expected)
|
||||
expectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Decryption failed for message \(i): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 10.0)
|
||||
}
|
||||
|
||||
@Test func sessionStaleDetection() throws {
|
||||
func testSessionStaleDetection() throws {
|
||||
// Test that sessions are properly marked as stale
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
@@ -399,10 +396,10 @@ struct NoiseProtocolTests {
|
||||
let sessions = aliceManager.getSessionsNeedingRekey()
|
||||
|
||||
// New session should not need rekey
|
||||
#expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
|
||||
XCTAssertTrue(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
|
||||
}
|
||||
|
||||
@Test func handshakeAfterDecryptionFailure() throws {
|
||||
func testHandshakeAfterDecryptionFailure() throws {
|
||||
// Test that handshake is properly initiated after decryption failure
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
@@ -411,25 +408,17 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Create a corrupted message
|
||||
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
|
||||
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||
encrypted[10] ^= 0xFF // Corrupt the data
|
||||
|
||||
// Decryption should fail
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
}
|
||||
}
|
||||
XCTAssertThrowsError(try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1))
|
||||
|
||||
// Bob should still have the session (it's not removed on single failure)
|
||||
#expect(bobManager.getSession(for: bobPeerID) != nil)
|
||||
XCTAssertNotNil(bobManager.getSession(for: TestConstants.testPeerID1))
|
||||
}
|
||||
|
||||
@Test func handshakeAlwaysAcceptedWithExistingSession() throws {
|
||||
func testHandshakeAlwaysAcceptedWithExistingSession() throws {
|
||||
// Test that handshake is always accepted even with existing valid session
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
@@ -438,38 +427,38 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Verify sessions are established
|
||||
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
|
||||
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
|
||||
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false)
|
||||
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false)
|
||||
|
||||
// Exchange messages to verify sessions work
|
||||
let testMessage = "Session works".data(using: .utf8)!
|
||||
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
#expect(decrypted == testMessage)
|
||||
let encrypted = try aliceManager.encrypt(testMessage, for: TestConstants.testPeerID2)
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
|
||||
XCTAssertEqual(decrypted, testMessage)
|
||||
|
||||
// Alice clears her session (simulating decryption failure)
|
||||
aliceManager.removeSession(for: alicePeerID)
|
||||
aliceManager.removeSession(for: TestConstants.testPeerID2)
|
||||
|
||||
// Alice initiates new handshake despite Bob having valid session
|
||||
let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
let newHandshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
||||
|
||||
// Bob should accept the new handshake even though he has a valid session
|
||||
let newHandshake2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session")
|
||||
let newHandshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake1)
|
||||
XCTAssertNotNil(newHandshake2, "Bob should accept handshake despite having valid session")
|
||||
|
||||
// Complete the handshake
|
||||
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
|
||||
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake2!)
|
||||
XCTAssertNotNil(newHandshake3)
|
||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake3!)
|
||||
|
||||
// Verify new sessions work
|
||||
let testMessage2 = "New session works".data(using: .utf8)!
|
||||
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
|
||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
|
||||
#expect(decrypted2 == testMessage2)
|
||||
let encrypted2 = try aliceManager.encrypt(testMessage2, for: TestConstants.testPeerID2)
|
||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: TestConstants.testPeerID1)
|
||||
XCTAssertEqual(decrypted2, testMessage2)
|
||||
}
|
||||
|
||||
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
||||
func testNonceDesynchronizationCausesRehandshake() throws {
|
||||
// Test that nonce desynchronization leads to proper re-handshake
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
@@ -479,43 +468,89 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Exchange messages normally
|
||||
for i in 0..<5 {
|
||||
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||
_ = try bobManager.decrypt(msg, from: bobPeerID)
|
||||
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||
_ = try bobManager.decrypt(msg, from: TestConstants.testPeerID1)
|
||||
}
|
||||
|
||||
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
|
||||
for i in 0..<3 {
|
||||
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||
}
|
||||
|
||||
// With nonce carried in packet, decryption should not throw here
|
||||
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
||||
#expect(throws: Never.self) {
|
||||
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
||||
}
|
||||
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
||||
XCTAssertNoThrow(try bobManager.decrypt(desyncMessage, from: TestConstants.testPeerID1))
|
||||
|
||||
// Bob clears session and initiates new handshake
|
||||
bobManager.removeSession(for: bobPeerID)
|
||||
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||
bobManager.removeSession(for: TestConstants.testPeerID1)
|
||||
let rehandshake1 = try bobManager.initiateHandshake(with: TestConstants.testPeerID1)
|
||||
|
||||
// Alice should accept despite having a "valid" (but desynced) session
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake1)
|
||||
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake1)
|
||||
XCTAssertNotNil(rehandshake2, "Alice should accept handshake to fix desync")
|
||||
|
||||
// Complete handshake
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: rehandshake2!)
|
||||
#expect(rehandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: rehandshake2!)
|
||||
XCTAssertNotNil(rehandshake3)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake3!)
|
||||
|
||||
// Verify communication works again
|
||||
let testResynced = "Resynced".data(using: .utf8)!
|
||||
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
|
||||
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
|
||||
#expect(decryptedResync == testResynced)
|
||||
let encryptedResync = try aliceManager.encrypt(testResynced, for: TestConstants.testPeerID2)
|
||||
let decryptedResync = try bobManager.decrypt(encryptedResync, from: TestConstants.testPeerID1)
|
||||
XCTAssertEqual(decryptedResync, testResynced)
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
func testHandshakePerformance() throws {
|
||||
measure {
|
||||
do {
|
||||
let alice = NoiseSession(peerID: "bob", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bob = NoiseSession(peerID: "alice", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
try performHandshake(initiator: alice, responder: bob)
|
||||
} catch {
|
||||
XCTFail("Handshake failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testEncryptionPerformance() throws {
|
||||
try establishSessions()
|
||||
let message = TestHelpers.generateRandomData(length: 1024)
|
||||
|
||||
measure {
|
||||
do {
|
||||
for _ in 0..<100 {
|
||||
let ciphertext = try aliceSession.encrypt(message)
|
||||
_ = try bobSession.decrypt(ciphertext)
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Encryption/decryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func establishSessions() throws {
|
||||
aliceSession = NoiseSession(
|
||||
peerID: TestConstants.testPeerID2,
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
bobSession = NoiseSession(
|
||||
peerID: TestConstants.testPeerID1,
|
||||
role: .responder,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: bobKey
|
||||
)
|
||||
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
}
|
||||
|
||||
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
|
||||
let msg1 = try initiator.startHandshake()
|
||||
let msg2 = try responder.processHandshakeMessage(msg1)!
|
||||
@@ -524,9 +559,9 @@ struct NoiseProtocolTests {
|
||||
}
|
||||
|
||||
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
|
||||
let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!
|
||||
let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)
|
||||
let msg1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
||||
let msg2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg1)!
|
||||
let msg3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: msg2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
// Tests for NIP-17 gift-wrapped private messages
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrProtocolTests {
|
||||
final class NostrProtocolTests: XCTestCase {
|
||||
|
||||
@Test func nip17MessageRoundTrip() throws {
|
||||
func testNIP17MessageRoundTrip() throws {
|
||||
// Create sender and recipient identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
#if DEBUG
|
||||
print("Sender pubkey: \(sender.publicKeyHex)")
|
||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||
#endif
|
||||
|
||||
// Create a test message
|
||||
let originalContent = "Hello from NIP-17 test!"
|
||||
@@ -30,8 +30,10 @@ struct NostrProtocolTests {
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||
#endif
|
||||
|
||||
// Decrypt the gift wrap
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -40,18 +42,20 @@ struct NostrProtocolTests {
|
||||
)
|
||||
|
||||
// Verify
|
||||
#expect(decryptedContent == originalContent)
|
||||
#expect(senderPubkey == sender.publicKeyHex)
|
||||
XCTAssertEqual(decryptedContent, originalContent)
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
// Verify timestamp is reasonable (within last minute)
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
||||
#expect(timeDiff < 60, "Message timestamp should be recent")
|
||||
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
|
||||
|
||||
#if DEBUG
|
||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
||||
func testGiftWrapUsesUniqueEphemeralKeys() throws {
|
||||
// Create identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -70,10 +74,11 @@ struct NostrProtocolTests {
|
||||
)
|
||||
|
||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||
#expect(message1.pubkey != message2.pubkey)
|
||||
|
||||
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
|
||||
#if DEBUG
|
||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||
#endif
|
||||
|
||||
// Both should decrypt successfully
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -85,11 +90,11 @@ struct NostrProtocolTests {
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
#expect(content1 == "Message 1")
|
||||
#expect(content2 == "Message 2")
|
||||
XCTAssertEqual(content1, "Message 1")
|
||||
XCTAssertEqual(content2, "Message 2")
|
||||
}
|
||||
|
||||
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||
func testDecryptionFailsWithWrongRecipient() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let wrongRecipient = try NostrIdentity.generate()
|
||||
@@ -102,20 +107,13 @@ struct NostrProtocolTests {
|
||||
)
|
||||
|
||||
// Try to decrypt with wrong recipient
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
XCTAssertThrowsError(try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)) { error in
|
||||
#if DEBUG
|
||||
print("Expected error when decrypting with wrong key: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +124,11 @@ struct NostrProtocolTests {
|
||||
|
||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||
let messageID = "TEST-MSG-DELIVERED-1"
|
||||
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
|
||||
|
||||
let embedded = try #require(
|
||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
||||
"Failed to embed delivered ack"
|
||||
)
|
||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
XCTFail("Failed to embed delivered ack")
|
||||
return
|
||||
}
|
||||
|
||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
@@ -141,7 +138,7 @@ struct NostrProtocolTests {
|
||||
)
|
||||
|
||||
// Ensure v2 format was used for ciphertext
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||
|
||||
// Decrypt as recipient
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -150,37 +147,39 @@ struct NostrProtocolTests {
|
||||
)
|
||||
|
||||
// Verify sender is correct
|
||||
#expect(senderPubkey == sender.publicKeyHex)
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
// Parse BitChat payload
|
||||
#expect(content.hasPrefix("bitchat1:"))
|
||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||
let packetData = try #require(Self.base64URLDecode(base64url))
|
||||
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
|
||||
|
||||
#expect(packet.type == MessageType.noiseEncrypted.rawValue)
|
||||
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
|
||||
|
||||
guard let packetData = Self.base64URLDecode(base64url),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
return XCTFail("Failed to decode bitchat packet")
|
||||
}
|
||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||
return XCTFail("Failed to decode NoisePayload")
|
||||
}
|
||||
switch payload.type {
|
||||
case .delivered:
|
||||
let mid = String(data: payload.data, encoding: .utf8)
|
||||
#expect(mid == messageID)
|
||||
XCTAssertEqual(mid, messageID)
|
||||
default:
|
||||
Issue.record("Unexpected payload type: \(payload.type)")
|
||||
XCTFail("Unexpected payload type: \(payload.type)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
||||
func testAckRoundTripNIP44V2_ReadReceipt() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
|
||||
let messageID = "TEST-MSG-READ-1"
|
||||
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
|
||||
let embedded = try #require(
|
||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
||||
"Failed to embed read ack"
|
||||
)
|
||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
XCTFail("Failed to embed read ack")
|
||||
return
|
||||
}
|
||||
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
@@ -188,28 +187,30 @@ struct NostrProtocolTests {
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(senderPubkey == sender.publicKeyHex)
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
#expect(content.hasPrefix("bitchat1:"))
|
||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||
let packetData = try #require(Self.base64URLDecode(base64url))
|
||||
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
|
||||
|
||||
#expect(packet.type == MessageType.noiseEncrypted.rawValue)
|
||||
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
|
||||
|
||||
guard let packetData = Self.base64URLDecode(base64url),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
return XCTFail("Failed to decode bitchat packet")
|
||||
}
|
||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||
return XCTFail("Failed to decode NoisePayload")
|
||||
}
|
||||
switch payload.type {
|
||||
case .readReceipt:
|
||||
let mid = String(data: payload.data, encoding: .utf8)
|
||||
#expect(mid == messageID)
|
||||
XCTAssertEqual(mid, messageID)
|
||||
default:
|
||||
Issue.record("Unexpected payload type: \(payload.type)")
|
||||
XCTFail("Unexpected payload type: \(payload.type)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct NotificationStreamAssemblerTests {
|
||||
final class NotificationStreamAssemblerTests: XCTestCase {
|
||||
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
||||
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
|
||||
return BitchatPacket(
|
||||
@@ -16,51 +15,60 @@ struct NotificationStreamAssemblerTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test func assemblesSingleFrameAcrossChunks() throws {
|
||||
func testAssemblesSingleFrameAcrossChunks() {
|
||||
var assembler = NotificationStreamAssembler()
|
||||
let packet = makePacket()
|
||||
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
|
||||
|
||||
#expect(BinaryProtocol.decode(frame) != nil)
|
||||
guard let frame = packet.toBinaryData(padding: false) else {
|
||||
return XCTFail("Failed to encode packet")
|
||||
}
|
||||
XCTAssertNotNil(BinaryProtocol.decode(frame))
|
||||
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
|
||||
#expect(payloadLen == packet.payload.count)
|
||||
XCTAssertEqual(payloadLen, packet.payload.count)
|
||||
|
||||
let splitIndex = min(20, max(1, frame.count / 2))
|
||||
let first = frame.prefix(splitIndex)
|
||||
let second = frame.suffix(from: splitIndex)
|
||||
#expect(first.count + second.count == frame.count)
|
||||
XCTAssertEqual(first.count + second.count, frame.count)
|
||||
|
||||
var result = assembler.append(first)
|
||||
#expect(result.frames.isEmpty)
|
||||
#expect(result.droppedPrefixes.isEmpty)
|
||||
#expect(!result.reset)
|
||||
XCTAssertTrue(result.frames.isEmpty)
|
||||
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
||||
XCTAssertFalse(result.reset)
|
||||
|
||||
result = assembler.append(second)
|
||||
#expect(result.frames.count == 1)
|
||||
#expect(result.droppedPrefixes.isEmpty)
|
||||
#expect(!result.reset)
|
||||
XCTAssertEqual(result.frames.count, 1)
|
||||
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
||||
XCTAssertFalse(result.reset)
|
||||
|
||||
let frameData = try #require(result.frames.first, "Missing frame data")
|
||||
#expect(frameData.count == frame.count)
|
||||
|
||||
let decoded = try #require(BinaryProtocol.decode(frameData), "Failed to decode frame")
|
||||
#expect(decoded.type == packet.type)
|
||||
#expect(decoded.payload == packet.payload)
|
||||
#expect(decoded.senderID == packet.senderID)
|
||||
#expect(decoded.timestamp == packet.timestamp)
|
||||
guard let frameData = result.frames.first else {
|
||||
return XCTFail("Missing frame data")
|
||||
}
|
||||
if frameData.count != frame.count {
|
||||
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
|
||||
return
|
||||
}
|
||||
guard let decoded = BinaryProtocol.decode(frameData) else {
|
||||
return XCTFail("Failed to decode frame")
|
||||
}
|
||||
XCTAssertEqual(decoded.type, packet.type)
|
||||
XCTAssertEqual(decoded.payload, packet.payload)
|
||||
XCTAssertEqual(decoded.senderID, packet.senderID)
|
||||
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
||||
|
||||
var directAssembler = NotificationStreamAssembler()
|
||||
let directResult = directAssembler.append(frame)
|
||||
#expect(directResult.frames.first?.count == frame.count)
|
||||
XCTAssertEqual(directResult.frames.first?.count, frame.count)
|
||||
}
|
||||
|
||||
@Test func assemblesMultipleFramesSequentially() throws {
|
||||
func testAssemblesMultipleFramesSequentially() {
|
||||
var assembler = NotificationStreamAssembler()
|
||||
let packet1 = makePacket(timestamp: 0xABC)
|
||||
let packet2 = makePacket(timestamp: 0xDEF)
|
||||
|
||||
let frame1 = try #require(packet1.toBinaryData(padding: false), "Failed to encode packet")
|
||||
let frame2 = try #require(packet2.toBinaryData(padding: false), "Failed to encode packet")
|
||||
guard let frame1 = packet1.toBinaryData(padding: false),
|
||||
let frame2 = packet2.toBinaryData(padding: false) else {
|
||||
return XCTFail("Failed to encode packets")
|
||||
}
|
||||
|
||||
var combined = Data()
|
||||
combined.append(frame1)
|
||||
@@ -69,31 +77,36 @@ struct NotificationStreamAssemblerTests {
|
||||
let secondChunk = combined.suffix(from: 20)
|
||||
|
||||
var result = assembler.append(firstChunk)
|
||||
#expect(result.frames.isEmpty)
|
||||
XCTAssertTrue(result.frames.isEmpty)
|
||||
|
||||
result = assembler.append(secondChunk)
|
||||
#expect(result.frames.count == 2)
|
||||
|
||||
let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame")
|
||||
let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), "Failed to decode frame")
|
||||
#expect(decoded1.timestamp == packet1.timestamp)
|
||||
#expect(decoded2.timestamp == packet2.timestamp)
|
||||
XCTAssertEqual(result.frames.count, 2)
|
||||
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
|
||||
let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
|
||||
return XCTFail("Failed to decode frames")
|
||||
}
|
||||
XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
|
||||
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
|
||||
}
|
||||
|
||||
@Test func dropsInvalidPrefixByte() throws {
|
||||
func testDropsInvalidPrefixByte() {
|
||||
var assembler = NotificationStreamAssembler()
|
||||
let packet = makePacket(timestamp: 0xF00)
|
||||
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
|
||||
guard let frame = packet.toBinaryData(padding: false) else {
|
||||
return XCTFail("Failed to encode packet")
|
||||
}
|
||||
var noisyFrame = Data([0x00])
|
||||
noisyFrame.append(frame)
|
||||
|
||||
let result = assembler.append(noisyFrame)
|
||||
#expect(result.droppedPrefixes == [0x00])
|
||||
#expect(result.frames.count == 1)
|
||||
#expect(result.reset == false)
|
||||
XCTAssertEqual(result.droppedPrefixes, [0x00])
|
||||
XCTAssertEqual(result.frames.count, 1)
|
||||
XCTAssertFalse(result.reset)
|
||||
|
||||
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
|
||||
#expect(decoded.timestamp == packet.timestamp)
|
||||
guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
|
||||
return XCTFail("Failed to decode frame after drop")
|
||||
}
|
||||
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
||||
}
|
||||
|
||||
func testAssemblesCompressedLargeFrame() throws {
|
||||
@@ -107,7 +120,9 @@ struct NotificationStreamAssemblerTests {
|
||||
mimeType: "application/octet-stream",
|
||||
content: largeContent
|
||||
)
|
||||
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
|
||||
guard let tlvPayload = filePacket.encode() else {
|
||||
return XCTFail("Failed to encode file packet")
|
||||
}
|
||||
|
||||
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
|
||||
let packet = BitchatPacket(
|
||||
@@ -121,31 +136,39 @@ struct NotificationStreamAssemblerTests {
|
||||
version: 2
|
||||
)
|
||||
|
||||
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet frame")
|
||||
guard let frame = packet.toBinaryData(padding: false) else {
|
||||
return XCTFail("Failed to encode packet frame")
|
||||
}
|
||||
|
||||
#expect(BinaryProtocol.Offsets.flags < frame.count)
|
||||
XCTAssertLessThan(BinaryProtocol.Offsets.flags, frame.count)
|
||||
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
|
||||
#expect((flags & BinaryProtocol.Flags.isCompressed) != 0, "Frame should be compressed for large payloads")
|
||||
XCTAssertNotEqual(flags & BinaryProtocol.Flags.isCompressed, 0, "Frame should be compressed for large payloads")
|
||||
|
||||
let splitIndex = min(4096, frame.count / 2)
|
||||
var result = assembler.append(frame.prefix(splitIndex))
|
||||
#expect(result.frames.isEmpty)
|
||||
XCTAssertTrue(result.frames.isEmpty)
|
||||
|
||||
result = assembler.append(frame.suffix(from: splitIndex))
|
||||
#expect(result.frames.count == 1)
|
||||
#expect(result.droppedPrefixes.isEmpty)
|
||||
#expect(result.reset == false)
|
||||
XCTAssertEqual(result.frames.count, 1)
|
||||
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
||||
XCTAssertFalse(result.reset)
|
||||
|
||||
let assembled = try #require(result.frames.first, "Missing assembled frame")
|
||||
#expect(assembled.count == frame.count)
|
||||
guard let assembled = result.frames.first else {
|
||||
return XCTFail("Missing assembled frame")
|
||||
}
|
||||
XCTAssertEqual(assembled.count, frame.count)
|
||||
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(assembled), "Failed to decode compressed frame")
|
||||
#expect(decodedPacket.payload.count == tlvPayload.count)
|
||||
guard let decodedPacket = BinaryProtocol.decode(assembled) else {
|
||||
return XCTFail("Failed to decode compressed frame")
|
||||
}
|
||||
XCTAssertEqual(decodedPacket.payload.count, tlvPayload.count)
|
||||
|
||||
let decodedFile = try #require(BitchatFilePacket.decode(decodedPacket.payload), "Failed to decode TLV payload")
|
||||
#expect(decodedFile.fileName == filePacket.fileName)
|
||||
#expect(decodedFile.mimeType == filePacket.mimeType)
|
||||
#expect(decodedFile.content.count == largeContent.count)
|
||||
#expect(decodedFile.content.prefix(32) == largeContent.prefix(32))
|
||||
guard let decodedFile = BitchatFilePacket.decode(decodedPacket.payload) else {
|
||||
return XCTFail("Failed to decode TLV payload")
|
||||
}
|
||||
XCTAssertEqual(decodedFile.fileName, filePacket.fileName)
|
||||
XCTAssertEqual(decodedFile.mimeType, filePacket.mimeType)
|
||||
XCTAssertEqual(decodedFile.content.count, largeContent.count)
|
||||
XCTAssertEqual(decodedFile.content.prefix(32), largeContent.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,29 +5,30 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct BinaryProtocolPaddingTests {
|
||||
@Test func padded_vs_unpadded_length() throws {
|
||||
final class BinaryProtocolPaddingTests: XCTestCase {
|
||||
func test_padded_vs_unpadded_length() throws {
|
||||
// Use helper to create a small test packet
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
|
||||
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
|
||||
#expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded")
|
||||
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") }
|
||||
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") }
|
||||
XCTAssertGreaterThanOrEqual(padded.count, unpadded.count, "Padded frame should be >= unpadded")
|
||||
}
|
||||
|
||||
@Test func decode_padded_and_unpadded_round_trip() throws {
|
||||
func test_decode_padded_and_unpadded_round_trip() throws {
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
|
||||
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
|
||||
let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded")
|
||||
#expect(dec1.type == packet.type)
|
||||
#expect(dec1.payload == packet.payload)
|
||||
|
||||
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
|
||||
let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded")
|
||||
#expect(dec2.type == packet.type)
|
||||
#expect(dec2.payload == packet.payload)
|
||||
// Padded
|
||||
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") }
|
||||
guard let dec1 = BinaryProtocol.decode(padded) else { return XCTFail("decode padded") }
|
||||
XCTAssertEqual(dec1.type, packet.type)
|
||||
XCTAssertEqual(dec1.payload, packet.payload)
|
||||
// Unpadded
|
||||
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") }
|
||||
guard let dec2 = BinaryProtocol.decode(unpadded) else { return XCTFail("decode unpadded") }
|
||||
XCTAssertEqual(dec2.type, packet.type)
|
||||
XCTAssertEqual(dec2.payload, packet.payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,170 +6,123 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct BinaryProtocolTests {
|
||||
final class BinaryProtocolTests: XCTestCase {
|
||||
|
||||
// MARK: - Basic Encoding/Decoding Tests
|
||||
|
||||
@Test func basicPacketEncodingDecoding() throws {
|
||||
func testBasicPacketEncodingDecoding() throws {
|
||||
let originalPacket = TestHelpers.createTestPacket()
|
||||
|
||||
let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet")
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet")
|
||||
// Encode
|
||||
guard let encodedData = BinaryProtocol.encode(originalPacket) else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode
|
||||
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
||||
XCTFail("Failed to decode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify
|
||||
#expect(decodedPacket.type == originalPacket.type)
|
||||
#expect(decodedPacket.ttl == originalPacket.ttl)
|
||||
#expect(decodedPacket.timestamp == originalPacket.timestamp)
|
||||
#expect(decodedPacket.payload == originalPacket.payload)
|
||||
XCTAssertEqual(decodedPacket.type, originalPacket.type)
|
||||
XCTAssertEqual(decodedPacket.ttl, originalPacket.ttl)
|
||||
XCTAssertEqual(decodedPacket.timestamp, originalPacket.timestamp)
|
||||
XCTAssertEqual(decodedPacket.payload, originalPacket.payload)
|
||||
|
||||
// Sender ID should match (accounting for padding)
|
||||
let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)
|
||||
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
|
||||
#expect(decodedSenderID == originalSenderID)
|
||||
XCTAssertEqual(decodedSenderID, originalSenderID)
|
||||
}
|
||||
|
||||
@Test func packetWithRecipient() throws {
|
||||
let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
|
||||
func testPacketWithRecipient() throws {
|
||||
let recipientID = TestConstants.testPeerID2
|
||||
let packet = TestHelpers.createTestPacket(recipientID: recipientID)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with recipient")
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient")
|
||||
|
||||
// Encode and decode
|
||||
guard let encodedData = BinaryProtocol.encode(packet),
|
||||
let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
||||
XCTFail("Failed to encode/decode packet with recipient")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify recipient
|
||||
#expect(decodedPacket.recipientID != nil)
|
||||
XCTAssertNotNil(decodedPacket.recipientID)
|
||||
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
||||
// TODO: Check if this is intended that the decoding only gets the first 8
|
||||
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
|
||||
XCTAssertTrue(String(data: decodedRecipientID!, encoding: .utf8) == recipientID)
|
||||
}
|
||||
|
||||
@Test func packetWithSignature() throws {
|
||||
let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature")
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with signature")
|
||||
func testPacketWithSignature() throws {
|
||||
let packet = TestHelpers.createTestPacket(
|
||||
signature: TestConstants.testSignature
|
||||
)
|
||||
|
||||
// Encode and decode
|
||||
guard let encodedData = BinaryProtocol.encode(packet),
|
||||
let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
||||
XCTFail("Failed to encode/decode packet with signature")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
#expect(decodedPacket.signature != nil)
|
||||
#expect(decodedPacket.signature == TestConstants.testSignature)
|
||||
}
|
||||
|
||||
@Test func packetWithRouteRoundTrip() throws {
|
||||
let route: [Data] = [
|
||||
try #require(Data(hexString: "0102030405060708")),
|
||||
try #require(Data(hexString: "1112131415161718")),
|
||||
try #require(Data(hexString: "2122232425262728"))
|
||||
]
|
||||
|
||||
var packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: route[0],
|
||||
recipientID: route.last,
|
||||
timestamp: 1_720_000_000_000,
|
||||
payload: Data("route-test".utf8),
|
||||
signature: nil,
|
||||
ttl: 6
|
||||
)
|
||||
packet.route = route
|
||||
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route")
|
||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
|
||||
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
|
||||
let decodedRoute = try #require(decoded.route)
|
||||
#expect(decodedRoute.count == route.count)
|
||||
for (expected, actual) in zip(route, decodedRoute) {
|
||||
#expect(actual == expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func packetWithRoutePadsShortHop() throws {
|
||||
let sender = try #require(Data(hexString: "0011223344556677"))
|
||||
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
|
||||
let shortHop = Data([0xAA, 0xBB, 0xCC])
|
||||
|
||||
var packet = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: sender,
|
||||
recipientID: destination,
|
||||
timestamp: 1_730_000_000_000,
|
||||
payload: Data("pad-test".utf8),
|
||||
signature: nil,
|
||||
ttl: 5
|
||||
)
|
||||
packet.route = [shortHop, destination]
|
||||
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route")
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route")
|
||||
let decodedRoute = try #require(decoded.route)
|
||||
let firstHop = try #require(decodedRoute.first)
|
||||
#expect(firstHop.count == BinaryProtocol.senderIDSize)
|
||||
#expect(firstHop.prefix(shortHop.count) == shortHop)
|
||||
let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count)
|
||||
#expect(paddingBytes.allSatisfy { $0 == 0 })
|
||||
}
|
||||
|
||||
@Test func packetWithRouteAndCompressedPayload() throws {
|
||||
let route: [Data] = [
|
||||
try #require(Data(hexString: "0101010101010101")),
|
||||
try #require(Data(hexString: "0202020202020202"))
|
||||
]
|
||||
let repeatedString = String(repeating: "compress-me", count: 150)
|
||||
var packet = BitchatPacket(
|
||||
type: 0x03,
|
||||
senderID: route[0],
|
||||
recipientID: route.last,
|
||||
timestamp: 1_740_000_000_000,
|
||||
payload: Data(repeatedString.utf8),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
packet.route = route
|
||||
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression")
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression")
|
||||
#expect(decoded.payload == Data(repeatedString.utf8))
|
||||
let decodedRoute = try #require(decoded.route)
|
||||
#expect(decodedRoute == route)
|
||||
XCTAssertNotNil(decodedPacket.signature)
|
||||
XCTAssertEqual(decodedPacket.signature, TestConstants.testSignature)
|
||||
}
|
||||
|
||||
// MARK: - Compression Tests
|
||||
|
||||
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||
func payloadCompression() throws {
|
||||
func testPayloadCompression() throws {
|
||||
// Create a large, compressible payload above current threshold (2048B)
|
||||
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
||||
let largePayload = repeatedString.data(using: .utf8)!
|
||||
|
||||
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
||||
|
||||
// Encode (should compress)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
||||
guard let encodedData = BinaryProtocol.encode(packet) else {
|
||||
XCTFail("Failed to encode packet with large payload")
|
||||
return
|
||||
}
|
||||
|
||||
// The encoded size should be smaller than uncompressed due to compression
|
||||
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
|
||||
guard let headerSize = BinaryProtocol.headerSize(for: packet.version) else {
|
||||
XCTFail("Invalid version")
|
||||
return
|
||||
}
|
||||
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
|
||||
XCTAssertLessThan(encodedData.count, uncompressedSize)
|
||||
|
||||
// Decode and verify
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
|
||||
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
||||
XCTFail("Failed to decode compressed packet")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(decodedPacket.payload == largePayload)
|
||||
XCTAssertEqual(decodedPacket.payload, largePayload)
|
||||
}
|
||||
|
||||
@Test("Small payloads should not be compressed")
|
||||
func smallPayloadNoCompression() throws {
|
||||
func testSmallPayloadNoCompression() throws {
|
||||
// Small payloads should not be compressed
|
||||
let smallPayload = "Hi".data(using: .utf8)!
|
||||
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
||||
#expect(decodedPacket.payload == smallPayload)
|
||||
|
||||
guard let encodedData = BinaryProtocol.encode(packet),
|
||||
let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
||||
XCTFail("Failed to encode/decode small packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decodedPacket.payload, smallPayload)
|
||||
}
|
||||
|
||||
// MARK: - Message Padding Tests
|
||||
|
||||
@Test func messagePadding() throws {
|
||||
func testMessagePadding() throws {
|
||||
let payloads = [
|
||||
"Short",
|
||||
String(repeating: "Medium length message content ", count: 10), // ~300 bytes
|
||||
@@ -181,32 +134,43 @@ struct BinaryProtocolTests {
|
||||
|
||||
for payload in payloads {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||
|
||||
guard let encodedData = BinaryProtocol.encode(packet) else {
|
||||
XCTFail("Failed to encode packet")
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
||||
let blockSizes = [256, 512, 1024, 2048]
|
||||
if encodedData.count <= 2048 {
|
||||
#expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
|
||||
XCTAssertTrue(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
|
||||
} else {
|
||||
// For very large payloads we expect no additional padding beyond raw size
|
||||
#expect(encodedData.count > 2048)
|
||||
XCTAssertGreaterThan(encodedData.count, 2048)
|
||||
}
|
||||
|
||||
encodedSizes.insert(encodedData.count)
|
||||
|
||||
// Verify decoding works
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet")
|
||||
#expect(String(data: decodedPacket.payload, encoding: .utf8) == payload)
|
||||
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
||||
XCTFail("Failed to decode padded packet")
|
||||
continue
|
||||
}
|
||||
|
||||
XCTAssertEqual(String(data: decodedPacket.payload, encoding: .utf8), payload)
|
||||
}
|
||||
|
||||
// Different payload sizes (within <=2048) may map to the same bucket depending on compression.
|
||||
// Require at least one padded size to be present.
|
||||
#expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
|
||||
XCTAssertGreaterThanOrEqual(encodedSizes.filter { $0 <= 2048 }.count, 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
|
||||
}
|
||||
|
||||
@Test func invalidPKCS7PaddingIsRejected() throws {
|
||||
func testInvalidPKCS7PaddingIsRejected() throws {
|
||||
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
|
||||
let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed")
|
||||
guard let enc0 = BinaryProtocol.encode(pkt) else {
|
||||
XCTFail("encode failed")
|
||||
return
|
||||
}
|
||||
// Force padding to known block for test stability
|
||||
var enc = MessagePadding.pad(enc0, toSize: 256)
|
||||
let unpadded = MessagePadding.unpad(enc)
|
||||
@@ -217,33 +181,39 @@ struct BinaryProtocolTests {
|
||||
let maybe = BinaryProtocol.decode(enc)
|
||||
// If decode still succeeds (nested pad edge case), at least ensure payload integrity
|
||||
if let pkt2 = maybe {
|
||||
#expect(pkt2.payload == pkt.payload)
|
||||
XCTAssertEqual(pkt2.payload, pkt.payload)
|
||||
} else {
|
||||
#expect(maybe == nil)
|
||||
XCTAssertNil(maybe)
|
||||
}
|
||||
} else {
|
||||
// If no padding was applied, just assert decode succeeds (nothing to test)
|
||||
#expect(BinaryProtocol.decode(enc) != nil)
|
||||
XCTAssertNotNil(BinaryProtocol.decode(enc))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Encoding/Decoding Tests
|
||||
|
||||
@Test func messageEncodingDecoding() throws {
|
||||
func testMessageEncodingDecoding() throws {
|
||||
let message = TestHelpers.createTestMessage()
|
||||
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary")
|
||||
guard let payload = message.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode message to binary")
|
||||
return
|
||||
}
|
||||
|
||||
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary")
|
||||
guard let decodedMessage = BitchatMessage(payload) else {
|
||||
XCTFail("Failed to decode message from binary")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(decodedMessage.content == message.content)
|
||||
#expect(decodedMessage.sender == message.sender)
|
||||
#expect(decodedMessage.senderPeerID == message.senderPeerID)
|
||||
#expect(decodedMessage.isPrivate == message.isPrivate)
|
||||
XCTAssertEqual(decodedMessage.content, message.content)
|
||||
XCTAssertEqual(decodedMessage.sender, message.sender)
|
||||
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
|
||||
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
|
||||
|
||||
// Timestamp should be close (within 1 second due to conversion)
|
||||
let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))
|
||||
#expect(timeDiff < 1)
|
||||
XCTAssertLessThan(timeDiff, 1.0)
|
||||
}
|
||||
|
||||
func testPrivateMessageEncoding() throws {
|
||||
@@ -252,22 +222,30 @@ struct BinaryProtocolTests {
|
||||
recipientNickname: TestConstants.testNickname2
|
||||
)
|
||||
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to encode private message")
|
||||
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message")
|
||||
guard let payload = message.toBinaryPayload(),
|
||||
let decodedMessage = BitchatMessage(payload) else {
|
||||
XCTFail("Failed to encode/decode private message")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(decodedMessage.isPrivate)
|
||||
#expect(decodedMessage.recipientNickname == TestConstants.testNickname2)
|
||||
XCTAssertTrue(decodedMessage.isPrivate)
|
||||
XCTAssertEqual(decodedMessage.recipientNickname, TestConstants.testNickname2)
|
||||
}
|
||||
|
||||
@Test func messageWithMentions() throws {
|
||||
func testMessageWithMentions() throws {
|
||||
let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]
|
||||
let message = TestHelpers.createTestMessage(mentions: mentions)
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to encode message with mentions")
|
||||
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions")
|
||||
#expect(decodedMessage.mentions == mentions)
|
||||
|
||||
guard let payload = message.toBinaryPayload(),
|
||||
let decodedMessage = BitchatMessage(payload) else {
|
||||
XCTFail("Failed to encode/decode message with mentions")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decodedMessage.mentions, mentions)
|
||||
}
|
||||
|
||||
@Test func relayMessageEncoding() throws {
|
||||
func testRelayMessageEncoding() throws {
|
||||
let message = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: TestConstants.testNickname1,
|
||||
@@ -277,77 +255,105 @@ struct BinaryProtocolTests {
|
||||
originalSender: TestConstants.testNickname3,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: TestConstants.testPeerID1,
|
||||
mentions: nil
|
||||
)
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to encode relay message")
|
||||
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message")
|
||||
#expect(decodedMessage.isRelay)
|
||||
#expect(decodedMessage.originalSender == TestConstants.testNickname3)
|
||||
|
||||
guard let payload = message.toBinaryPayload(),
|
||||
let decodedMessage = BitchatMessage(payload) else {
|
||||
XCTFail("Failed to encode/decode relay message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(decodedMessage.isRelay)
|
||||
XCTAssertEqual(decodedMessage.originalSender, TestConstants.testNickname3)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases and Error Handling
|
||||
|
||||
@Test("Too small data")
|
||||
func invalidDataDecoding() throws {
|
||||
func testInvalidDataDecoding() {
|
||||
// Too small data
|
||||
let tooSmall = Data(repeating: 0, count: 5)
|
||||
#expect(BinaryProtocol.decode(tooSmall) == nil)
|
||||
XCTAssertNil(BinaryProtocol.decode(tooSmall))
|
||||
|
||||
// Random data
|
||||
let random = TestHelpers.generateRandomData(length: 100)
|
||||
#expect(BinaryProtocol.decode(random) == nil)
|
||||
XCTAssertNil(BinaryProtocol.decode(random))
|
||||
|
||||
// Corrupted header
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
|
||||
guard var encoded = BinaryProtocol.encode(packet) else {
|
||||
XCTFail("Failed to encode test packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Corrupt the version byte
|
||||
encoded[0] = 0xFF
|
||||
#expect(BinaryProtocol.decode(encoded) == nil)
|
||||
XCTAssertNil(BinaryProtocol.decode(encoded))
|
||||
}
|
||||
|
||||
@Test("Test maximum size handling")
|
||||
func largeMessageHandling() throws {
|
||||
func testLargeMessageHandling() throws {
|
||||
// Test maximum size handling
|
||||
let largeContent = String(repeating: "X", count: 65535) // Max uint16
|
||||
let message = TestHelpers.createTestMessage(content: largeContent)
|
||||
let payload = try #require(message.toBinaryPayload(), "Failed to handle large message")
|
||||
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message")
|
||||
#expect(decodedMessage.content == largeContent)
|
||||
|
||||
guard let payload = message.toBinaryPayload(),
|
||||
let decodedMessage = BitchatMessage(payload) else {
|
||||
XCTFail("Failed to handle large message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decodedMessage.content, largeContent)
|
||||
}
|
||||
|
||||
@Test("Test message with empty content")
|
||||
func emptyFieldsHandling() throws {
|
||||
func testEmptyFieldsHandling() throws {
|
||||
// Test message with empty content
|
||||
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
||||
let payload = try #require(emptyMessage.toBinaryPayload(), "Failed to handle empty message")
|
||||
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message")
|
||||
#expect(decodedMessage.content.isEmpty)
|
||||
|
||||
guard let payload = emptyMessage.toBinaryPayload(),
|
||||
let decodedMessage = BitchatMessage(payload) else {
|
||||
XCTFail("Failed to handle empty message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decodedMessage.content, "")
|
||||
}
|
||||
|
||||
// MARK: - Protocol Version Tests
|
||||
|
||||
@Test("Test with supported version (version is always 1 in init)")
|
||||
func protocolVersionHandling() throws {
|
||||
func testProtocolVersionHandling() throws {
|
||||
// Test with supported version (version is always 1 in init)
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version")
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version")
|
||||
#expect(decoded.version == 1)
|
||||
|
||||
guard let encoded = BinaryProtocol.encode(packet),
|
||||
let decoded = BinaryProtocol.decode(encoded) else {
|
||||
XCTFail("Failed to encode/decode packet with version")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.version, 1)
|
||||
}
|
||||
|
||||
@Test("Create packet data with unsupported version")
|
||||
func unsupportedProtocolVersion() throws {
|
||||
func testUnsupportedProtocolVersion() throws {
|
||||
// Create packet data with unsupported version
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||
|
||||
guard var encoded = BinaryProtocol.encode(packet) else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Manually change version byte to unsupported value
|
||||
encoded[0] = 99 // Unsupported version
|
||||
|
||||
// Should fail to decode
|
||||
#expect(BinaryProtocol.decode(encoded) == nil)
|
||||
XCTAssertNil(BinaryProtocol.decode(encoded))
|
||||
}
|
||||
|
||||
// MARK: - Bounds Checking Tests (Crash Prevention)
|
||||
|
||||
@Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available")
|
||||
func malformedPacketWithInvalidPayloadLength() throws {
|
||||
func testMalformedPacketWithInvalidPayloadLength() throws {
|
||||
// Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available
|
||||
var malformedData = Data()
|
||||
|
||||
// Valid header (13 bytes)
|
||||
@@ -377,17 +383,20 @@ struct BinaryProtocolTests {
|
||||
}
|
||||
|
||||
// Total data is now 30 bytes, but payloadLength claims 193
|
||||
#expect(malformedData.count == 30)
|
||||
XCTAssertEqual(malformedData.count, 30)
|
||||
|
||||
// This should not crash - should return nil gracefully
|
||||
let result = BinaryProtocol.decode(malformedData)
|
||||
#expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash")
|
||||
XCTAssertNil(result, "Malformed packet with invalid payload length should return nil, not crash")
|
||||
}
|
||||
|
||||
@Test("Test various truncation scenarios")
|
||||
func truncatedPacketHandling() throws {
|
||||
func testTruncatedPacketHandling() throws {
|
||||
// Test various truncation scenarios
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
|
||||
guard let validEncoded = BinaryProtocol.encode(packet) else {
|
||||
XCTFail("Failed to encode test packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Test truncation at various points
|
||||
let truncationPoints = [0, 5, 10, 15, 20, 25]
|
||||
@@ -395,12 +404,12 @@ struct BinaryProtocolTests {
|
||||
for point in truncationPoints {
|
||||
let truncated = validEncoded.prefix(point)
|
||||
let result = BinaryProtocol.decode(truncated)
|
||||
#expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash")
|
||||
XCTAssertNil(result, "Truncated packet at \(point) bytes should return nil, not crash")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Test compressed packet with invalid original size")
|
||||
func malformedCompressedPacket() throws {
|
||||
func testMalformedCompressedPacket() throws {
|
||||
// Test compressed packet with invalid original size
|
||||
var malformedData = Data()
|
||||
|
||||
// Valid header
|
||||
@@ -429,11 +438,11 @@ struct BinaryProtocolTests {
|
||||
|
||||
// Should handle this gracefully
|
||||
let result = BinaryProtocol.decode(malformedData)
|
||||
#expect(result == nil, "Malformed compressed packet should return nil, not crash")
|
||||
XCTAssertNil(result, "Malformed compressed packet should return nil, not crash")
|
||||
}
|
||||
|
||||
@Test("Test packet claiming extremely large payload")
|
||||
func excessivelyLargePayloadLength() throws {
|
||||
func testExcessivelyLargePayloadLength() throws {
|
||||
// Test packet claiming extremely large payload
|
||||
var malformedData = Data()
|
||||
|
||||
// Valid header
|
||||
@@ -462,11 +471,11 @@ struct BinaryProtocolTests {
|
||||
|
||||
// Should handle this gracefully without trying to allocate massive amounts of memory
|
||||
let result = BinaryProtocol.decode(malformedData)
|
||||
#expect(result == nil, "Packet with excessive payload length should return nil, not crash")
|
||||
XCTAssertNil(result, "Packet with excessive payload length should return nil, not crash")
|
||||
}
|
||||
|
||||
@Test("Test compressed packet with unreasonable original size")
|
||||
func compressedPacketWithInvalidOriginalSize() throws {
|
||||
func testCompressedPacketWithInvalidOriginalSize() throws {
|
||||
// Test compressed packet with unreasonable original size
|
||||
var malformedData = Data()
|
||||
|
||||
// Valid header
|
||||
@@ -504,11 +513,11 @@ struct BinaryProtocolTests {
|
||||
}
|
||||
|
||||
let result = BinaryProtocol.decode(malformedData)
|
||||
#expect(result == nil, "Compressed packet with invalid original size should return nil, not crash")
|
||||
XCTAssertNil(result, "Compressed packet with invalid original size should return nil, not crash")
|
||||
}
|
||||
|
||||
@Test("Test packet designed to cause integer overflow")
|
||||
func maliciousPacketWithIntegerOverflow() throws {
|
||||
func testMaliciousPacketWithIntegerOverflow() throws {
|
||||
// Test packet designed to cause integer overflow
|
||||
var maliciousData = Data()
|
||||
|
||||
// Valid header
|
||||
@@ -543,24 +552,27 @@ struct BinaryProtocolTests {
|
||||
|
||||
// Should handle gracefully without integer overflow issues
|
||||
let result = BinaryProtocol.decode(maliciousData)
|
||||
#expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash")
|
||||
XCTAssertNil(result, "Malicious packet designed for integer overflow should return nil, not crash")
|
||||
}
|
||||
|
||||
@Test("Test packets with incomplete headers")
|
||||
func partialHeaderData() throws {
|
||||
func testPartialHeaderData() throws {
|
||||
// Test packets with incomplete headers
|
||||
let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes
|
||||
|
||||
for size in headerSizes {
|
||||
let partialData = Data(repeating: 0x01, count: size)
|
||||
let result = BinaryProtocol.decode(partialData)
|
||||
#expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash")
|
||||
XCTAssertNil(result, "Partial header data (\(size) bytes) should return nil, not crash")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Test exact boundary conditions")
|
||||
func boundaryConditions() throws {
|
||||
func testBoundaryConditions() throws {
|
||||
// Test exact boundary conditions
|
||||
let packet = TestHelpers.createTestPacket()
|
||||
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
|
||||
guard let validEncoded = BinaryProtocol.encode(packet) else {
|
||||
XCTFail("Failed to encode test packet")
|
||||
return
|
||||
}
|
||||
|
||||
// If truncation only removes padding, decode may still succeed. Compute unpadded size.
|
||||
let unpadded = MessagePadding.unpad(validEncoded)
|
||||
@@ -568,7 +580,7 @@ struct BinaryProtocolTests {
|
||||
let cut = max(1, unpadded.count - 10)
|
||||
let truncatedCore = unpadded.prefix(cut)
|
||||
let result = BinaryProtocol.decode(truncatedCore)
|
||||
#expect(result == nil, "Truncated core frame should return nil, not crash")
|
||||
XCTAssertNil(result, "Truncated core frame should return nil, not crash")
|
||||
|
||||
// Test minimum valid size - create a valid minimal packet
|
||||
var minData = Data()
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
//
|
||||
// MeshTopologyTrackerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct MeshTopologyTrackerTests {
|
||||
private func hex(_ value: String) throws -> Data {
|
||||
try #require(Data(hexString: value))
|
||||
}
|
||||
|
||||
@Test func directLinkProducesRoute() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0102030405060708")
|
||||
let b = try hex("1112131415161718")
|
||||
|
||||
tracker.recordDirectLink(between: a, and: b)
|
||||
let route = try #require(tracker.computeRoute(from: a, to: b))
|
||||
#expect(route == [a, b])
|
||||
}
|
||||
|
||||
@Test func multiHopRouteComputation() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0001020304050607")
|
||||
let b = try hex("1011121314151617")
|
||||
let c = try hex("2021222324252627")
|
||||
let d = try hex("3031323334353637")
|
||||
|
||||
tracker.recordDirectLink(between: a, and: b)
|
||||
tracker.recordDirectLink(between: b, and: c)
|
||||
tracker.recordDirectLink(between: c, and: d)
|
||||
|
||||
let route = try #require(tracker.computeRoute(from: a, to: d))
|
||||
#expect(route == [a, b, c, d])
|
||||
}
|
||||
|
||||
@Test func recordRouteAddsEdges() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
var a = Data([0xAA, 0xBB, 0xCC])
|
||||
let b = try hex("4445464748494A4B")
|
||||
let c = try hex("5455565758595A5B")
|
||||
|
||||
tracker.recordRoute([a, b, c])
|
||||
|
||||
a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count))
|
||||
let route = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(route.first == a)
|
||||
#expect(route.last == c)
|
||||
}
|
||||
|
||||
@Test func removingDirectLinkBreaksRoute() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
|
||||
tracker.recordDirectLink(between: a, and: b)
|
||||
tracker.recordDirectLink(between: b, and: c)
|
||||
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(initialRoute == [a, b, c])
|
||||
|
||||
tracker.removeDirectLink(between: b, and: c)
|
||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||
}
|
||||
|
||||
@Test func removingPeerClearsEdges() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0F0E0D0C0B0A0908")
|
||||
let b = try hex("0A0B0C0D0E0F0001")
|
||||
let c = try hex("0011223344556677")
|
||||
|
||||
tracker.recordRoute([a, b, c])
|
||||
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(initialRoute == [a, b, c])
|
||||
|
||||
tracker.removePeer(b)
|
||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,6 +14,11 @@ struct TestConstants {
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
static let testPeerID1: PeerID = "PEER1234"
|
||||
static let testPeerID2: PeerID = "PEER5678"
|
||||
static let testPeerID3: PeerID = "PEER9012"
|
||||
static let testPeerID4: PeerID = "PEER3456"
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
static let testNickname2 = "Bob"
|
||||
static let testNickname3 = "Charlie"
|
||||
|
||||
@@ -30,7 +30,7 @@ final class TestHelpers {
|
||||
static func createTestMessage(
|
||||
content: String = TestConstants.testMessage1,
|
||||
sender: String = TestConstants.testNickname1,
|
||||
senderPeerID: PeerID = PeerID(str: UUID().uuidString),
|
||||
senderPeerID: PeerID = TestConstants.testPeerID1,
|
||||
isPrivate: Bool = false,
|
||||
recipientNickname: String? = nil,
|
||||
mentions: [String]? = nil
|
||||
@@ -51,7 +51,7 @@ final class TestHelpers {
|
||||
|
||||
static func createTestPacket(
|
||||
type: UInt8 = 0x01,
|
||||
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||
senderID: PeerID = TestConstants.testPeerID1,
|
||||
recipientID: PeerID? = nil,
|
||||
payload: Data = "test payload".data(using: .utf8)!,
|
||||
signature: Data? = nil,
|
||||
@@ -90,7 +90,7 @@ final class TestHelpers {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
throw TestError.timeout
|
||||
}
|
||||
try await sleep(0.01)
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ final class TestHelpers {
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
try await sleep(1)
|
||||
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
throw TestError.timeout
|
||||
}
|
||||
|
||||
@@ -121,6 +121,14 @@ enum TestError: Error {
|
||||
case testFailure(String)
|
||||
}
|
||||
|
||||
func sleep(_ seconds: TimeInterval) async throws {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
// MARK: - PeerID String Helpers
|
||||
|
||||
/// Raw String can be passed as PeerID
|
||||
extension PeerID: @retroactive ExpressibleByStringLiteral {
|
||||
public init(stringLiteral value: String) {
|
||||
self.init(str: value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpolated String can be passed as PeerID
|
||||
extension PeerID: @retroactive ExpressibleByStringInterpolation {}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
struct PeerIDTests {
|
||||
final class PeerIDTests: XCTestCase {
|
||||
|
||||
private let hex16 = "0011223344556677"
|
||||
private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||
|
||||
@@ -22,225 +22,212 @@ struct PeerIDTests {
|
||||
|
||||
// MARK: - Empty prefix
|
||||
|
||||
@Test func empty_prefix_with16() {
|
||||
func test_init_empty_prefix_with16() {
|
||||
let peerID = PeerID(str: hex16)
|
||||
#expect(peerID.id == hex16)
|
||||
#expect(peerID.bare == hex16)
|
||||
#expect(peerID.prefix == .empty)
|
||||
XCTAssertEqual(peerID.id, hex16)
|
||||
XCTAssertEqual(peerID.bare, hex16)
|
||||
XCTAssertEqual(peerID.prefix, .empty)
|
||||
}
|
||||
|
||||
@Test func empty_prefix_with64() {
|
||||
func test_init_empty_prefix_with64() {
|
||||
let peerID = PeerID(str: hex64)
|
||||
#expect(peerID.id == hex64)
|
||||
#expect(peerID.bare == hex64)
|
||||
#expect(peerID.prefix == .empty)
|
||||
XCTAssertEqual(peerID.id, hex64)
|
||||
XCTAssertEqual(peerID.bare, hex64)
|
||||
XCTAssertEqual(peerID.prefix, .empty)
|
||||
}
|
||||
|
||||
// MARK: - Mesh prefix
|
||||
|
||||
@Test func mesh_prefix_with16() {
|
||||
func test_init_mesh_prefix_with16() {
|
||||
let str = "mesh:" + hex16
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex16)
|
||||
#expect(peerID.prefix == .mesh)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex16)
|
||||
XCTAssertEqual(peerID.prefix, .mesh)
|
||||
}
|
||||
|
||||
@Test func mesh_prefix_with64() {
|
||||
func test_init_mesh_prefix_with64() {
|
||||
let str = "mesh:" + hex64
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex64)
|
||||
#expect(peerID.prefix == .mesh)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex64)
|
||||
XCTAssertEqual(peerID.prefix, .mesh)
|
||||
}
|
||||
|
||||
// MARK: - Name prefix
|
||||
|
||||
@Test func name_prefix() {
|
||||
func test_init_name_prefix() {
|
||||
let str = "name:some_name"
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == "some_name")
|
||||
#expect(peerID.prefix == .name)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, "some_name")
|
||||
XCTAssertEqual(peerID.prefix, .name)
|
||||
}
|
||||
|
||||
// MARK: - Noise prefix
|
||||
|
||||
@Test func noise_prefix_with16() {
|
||||
func test_init_noise_prefix_with16() {
|
||||
let str = "noise:" + hex16
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex16)
|
||||
#expect(peerID.prefix == .noise)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex16)
|
||||
XCTAssertEqual(peerID.prefix, .noise)
|
||||
}
|
||||
|
||||
@Test func noise_prefix_with64() {
|
||||
func test_init_noise_prefix_with64() {
|
||||
let str = "noise:" + hex64
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex64)
|
||||
#expect(peerID.prefix == .noise)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex64)
|
||||
XCTAssertEqual(peerID.prefix, .noise)
|
||||
}
|
||||
|
||||
// MARK: - GeoDM prefix
|
||||
|
||||
@Test func geoDM_prefix_with16() {
|
||||
func test_init_geoDM_prefix_with16() {
|
||||
let str = "nostr_" + hex16
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex16)
|
||||
#expect(peerID.prefix == .geoDM)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex16)
|
||||
XCTAssertEqual(peerID.prefix, .geoDM)
|
||||
}
|
||||
|
||||
@Test func geoDM_prefix_with64() {
|
||||
func test_init_geoDM_prefix_with64() {
|
||||
let str = "nostr_" + hex64
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex64)
|
||||
#expect(peerID.prefix == .geoDM)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex64)
|
||||
XCTAssertEqual(peerID.prefix, .geoDM)
|
||||
}
|
||||
|
||||
// MARK: - GeoChat prefix
|
||||
|
||||
@Test func geoChat_prefix_with16() {
|
||||
func test_init_geoChat_prefix_with16() {
|
||||
let str = "nostr:" + hex16
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex16)
|
||||
#expect(peerID.prefix == .geoChat)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex16)
|
||||
XCTAssertEqual(peerID.prefix, .geoChat)
|
||||
}
|
||||
|
||||
@Test func geoChat_prefix_with64() {
|
||||
func test_init_geoChat_prefix_with64() {
|
||||
let str = "nostr:" + hex64
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == hex64)
|
||||
#expect(peerID.prefix == .geoChat)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, hex64)
|
||||
XCTAssertEqual(peerID.prefix, .geoChat)
|
||||
}
|
||||
|
||||
// MARK: - Edge cases
|
||||
|
||||
@Test func with_unknown_prefix() {
|
||||
func test_init_with_unknown_prefix() {
|
||||
let str = "unknown:" + hex16
|
||||
let peerID = PeerID(str: str)
|
||||
// Falls back to .empty
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == str)
|
||||
#expect(peerID.prefix == .empty)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, str)
|
||||
XCTAssertEqual(peerID.prefix, .empty)
|
||||
}
|
||||
|
||||
@Test func with_only_prefix_no_bare() {
|
||||
func test_init_with_only_prefix_no_bare() {
|
||||
let str = "mesh:"
|
||||
let peerID = PeerID(str: str)
|
||||
#expect(peerID.id == str)
|
||||
#expect(peerID.bare == "")
|
||||
#expect(peerID.prefix == .mesh)
|
||||
XCTAssertEqual(peerID.id, str)
|
||||
XCTAssertEqual(peerID.bare, "")
|
||||
XCTAssertEqual(peerID.prefix, .mesh)
|
||||
}
|
||||
|
||||
// MARK: - init?(data:)
|
||||
|
||||
@Test func data_valid_utf8() {
|
||||
func test_init_data_valid_utf8() {
|
||||
let peerID = PeerID(data: Data(hex16.utf8))
|
||||
#expect(peerID != nil)
|
||||
#expect(peerID?.bare == hex16)
|
||||
#expect(peerID?.prefix == .empty)
|
||||
XCTAssertNotNil(peerID)
|
||||
XCTAssertEqual(peerID?.bare, hex16)
|
||||
XCTAssertEqual(peerID?.prefix, .empty)
|
||||
}
|
||||
|
||||
@Test func data_invalid_utf8() {
|
||||
func test_init_data_invalid_utf8() {
|
||||
// Random invalid UTF8
|
||||
let bytes: [UInt8] = [0xFF, 0xFE, 0xFA]
|
||||
let peerID = PeerID(data: Data(bytes))
|
||||
#expect(peerID == nil)
|
||||
XCTAssertNil(peerID)
|
||||
}
|
||||
|
||||
// MARK: - init(str: Substring)
|
||||
|
||||
@Test func substring() {
|
||||
func test_init_substring() {
|
||||
let substring = hex64.prefix(16)
|
||||
let peerID = PeerID(str: substring)
|
||||
#expect(peerID.id == String(substring))
|
||||
#expect(peerID.bare == String(substring))
|
||||
#expect(peerID.prefix == .empty)
|
||||
XCTAssertEqual(peerID.id, String(substring))
|
||||
XCTAssertEqual(peerID.bare, String(substring))
|
||||
XCTAssertEqual(peerID.prefix, .empty)
|
||||
}
|
||||
|
||||
// MARK: - init(nostr_ pubKey:)
|
||||
|
||||
@Test func nostrUnderscore_pubKey() {
|
||||
func test_init_nostrUnderscore_pubKey() {
|
||||
let pubKey = hex64
|
||||
let peerID = PeerID(nostr_: pubKey)
|
||||
#expect(peerID.id == "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
|
||||
#expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
|
||||
#expect(peerID.prefix == .geoDM)
|
||||
XCTAssertEqual(peerID.id, "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
|
||||
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
|
||||
XCTAssertEqual(peerID.prefix, .geoDM)
|
||||
}
|
||||
|
||||
// MARK: - init(nostr pubKey:)
|
||||
|
||||
@Test func nostr_pubKey() {
|
||||
func test_init_nostr_pubKey() {
|
||||
let pubKey = hex64
|
||||
let peerID = PeerID(nostr: pubKey)
|
||||
#expect(peerID.id == "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
|
||||
#expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
|
||||
#expect(peerID.prefix == .geoChat)
|
||||
XCTAssertEqual(peerID.id, "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
|
||||
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
|
||||
XCTAssertEqual(peerID.prefix, .geoChat)
|
||||
}
|
||||
|
||||
// MARK: - init(publicKey:)
|
||||
|
||||
@Test func publicKey_derivesFingerprint() {
|
||||
func test_init_publicKey_derivesFingerprint() {
|
||||
let publicKey = Data(hex64.utf8)
|
||||
let expected = publicKey.sha256Fingerprint().prefix(16)
|
||||
let peerID = PeerID(publicKey: publicKey)
|
||||
#expect(peerID.bare == String(expected))
|
||||
#expect(peerID.prefix == .empty)
|
||||
XCTAssertEqual(peerID.bare, String(expected))
|
||||
XCTAssertEqual(peerID.prefix, .empty)
|
||||
}
|
||||
|
||||
// MARK: - toShort()
|
||||
|
||||
@Test func toShort_whenNoiseKeyExists() {
|
||||
func test_toShort_whenNoiseKeyExists() {
|
||||
let peerID = PeerID(str: hex64)
|
||||
let short = peerID.toShort()
|
||||
|
||||
// `toShort()` should derive 16-hex peerID
|
||||
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
|
||||
#expect(short.bare == String(expected))
|
||||
#expect(short.prefix == .empty)
|
||||
|
||||
XCTAssertEqual(short.bare, String(expected))
|
||||
XCTAssertEqual(short.prefix, .empty)
|
||||
}
|
||||
|
||||
@Test func toShort_whenNoiseKeyExists_withNoisePrefix() {
|
||||
func test_toShort_whenNoiseKeyExists_withNoisePrefix() {
|
||||
let peerID = PeerID(str: "noise:" + hex64)
|
||||
let short = peerID.toShort()
|
||||
|
||||
// `toShort()` should derive 16-hex peerID
|
||||
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
|
||||
#expect(short.bare == String(expected))
|
||||
#expect(short.prefix == .empty)
|
||||
#expect(peerID.prefix == .noise)
|
||||
|
||||
XCTAssertEqual(short.bare, String(expected))
|
||||
XCTAssertEqual(short.prefix, .empty)
|
||||
XCTAssertEqual(peerID.prefix, .noise)
|
||||
}
|
||||
|
||||
@Test func toShort_whenNoNoiseKey() {
|
||||
func test_toShort_whenNoNoiseKey() {
|
||||
let peerID = PeerID(str: "some_random_key")
|
||||
let short = peerID.toShort()
|
||||
#expect(short == peerID)
|
||||
}
|
||||
|
||||
@Test func routingData_fromShortID() throws {
|
||||
let peerID = PeerID(str: hex16)
|
||||
let routing = try #require(peerID.routingData)
|
||||
#expect(routing.count == 8)
|
||||
#expect(routing == Data(hexString: hex16))
|
||||
}
|
||||
|
||||
@Test func routingData_fromNoiseKey() throws {
|
||||
let peerID = PeerID(str: hex64)
|
||||
let routing = try #require(peerID.routingData)
|
||||
let expectedShort = peerID.toShort()
|
||||
#expect(routing == Data(hexString: expectedShort.id))
|
||||
}
|
||||
|
||||
@Test func routingPeerRoundTrip() throws {
|
||||
let raw = try #require(Data(hexString: hex16))
|
||||
let peerID = try #require(PeerID(routingData: raw))
|
||||
#expect(peerID.routingData == raw)
|
||||
XCTAssertEqual(short, peerID) // unchanged
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
@Test func codable_emptyPrefix() throws {
|
||||
func test_codable_emptyPrefix() throws {
|
||||
struct Dummy: Codable, Equatable {
|
||||
let name: String
|
||||
let peerID: PeerID
|
||||
@@ -250,13 +237,13 @@ struct PeerIDTests {
|
||||
let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}"
|
||||
|
||||
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
|
||||
#expect(decoded.peerID == PeerID(str: str))
|
||||
XCTAssertEqual(decoded.peerID, PeerID(str: str))
|
||||
|
||||
let encoded = try encoder.encode(decoded)
|
||||
#expect(String(data: encoded, encoding: .utf8) == jsonString)
|
||||
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString)
|
||||
}
|
||||
|
||||
@Test func codable_withPrefix() throws {
|
||||
func test_codable_withPrefix() throws {
|
||||
struct Dummy: Codable, Equatable {
|
||||
let peerID: PeerID
|
||||
}
|
||||
@@ -265,165 +252,193 @@ struct PeerIDTests {
|
||||
let jsonString = "{\"peerID\":\"\(str)\"}"
|
||||
|
||||
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
|
||||
#expect(decoded.peerID == PeerID(str: str))
|
||||
#expect(decoded.peerID.bare == hex16)
|
||||
#expect(decoded.peerID.prefix == .geoDM)
|
||||
XCTAssertEqual(decoded.peerID, PeerID(str: str))
|
||||
XCTAssertEqual(decoded.peerID.bare, hex16)
|
||||
XCTAssertEqual(decoded.peerID.prefix, .geoDM)
|
||||
|
||||
let encoded = try encoder.encode(decoded)
|
||||
#expect(String(data: encoded, encoding: .utf8) == jsonString)
|
||||
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString)
|
||||
}
|
||||
|
||||
@Test func codable_multiplePrefixes() throws {
|
||||
func test_codable_multiplePrefixes() throws {
|
||||
// Loop across all Prefix cases (except .empty since already tested)
|
||||
for prefix in PeerID.Prefix.allCases where prefix != .empty {
|
||||
let bare = hex16
|
||||
let str = prefix.rawValue + bare
|
||||
|
||||
let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8))
|
||||
#expect(decoded.prefix == prefix)
|
||||
#expect(decoded.bare == bare)
|
||||
XCTAssertEqual(decoded.prefix, prefix)
|
||||
XCTAssertEqual(decoded.bare, bare)
|
||||
|
||||
let encoded = try encoder.encode(decoded)
|
||||
#expect(String(data: encoded, encoding: .utf8) == "\"\(str)\"")
|
||||
XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"\(str)\"")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
@Test func comparable_sorting_and_equality() {
|
||||
func test_comparable_sorting_and_equality() {
|
||||
let p1 = PeerID(str: "aaa")
|
||||
let p2 = PeerID(str: "bbb")
|
||||
let p3 = PeerID(str: "BBB")
|
||||
let p3 = PeerID(str: "bbb")
|
||||
|
||||
#expect(p1 < p2)
|
||||
#expect(p2 >= p1)
|
||||
#expect(p2 == p3)
|
||||
XCTAssertTrue(p1 < p2)
|
||||
XCTAssertFalse(p2 < p1)
|
||||
XCTAssertEqual(p2, p3)
|
||||
|
||||
let sorted = [p2, p1].sorted()
|
||||
#expect(sorted == [p1, p2])
|
||||
XCTAssertEqual(sorted, [p1, p2])
|
||||
}
|
||||
|
||||
@Test func equality() {
|
||||
let peerID = PeerID(str: "aaa")
|
||||
func test_equality() {
|
||||
let string = "aaa"
|
||||
let peerID = PeerID(str: string)
|
||||
let badString = "bbb"
|
||||
|
||||
// Regular PeerID <> PeerID
|
||||
#expect(peerID == PeerID(str: "AAA"))
|
||||
#expect(peerID == Optional(PeerID(str: "AAA")))
|
||||
#expect(PeerID(str: "AAA") == peerID)
|
||||
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
|
||||
// PeerID == String
|
||||
XCTAssertTrue(peerID == string)
|
||||
XCTAssertTrue(peerID == Optional(string))
|
||||
XCTAssertTrue(Optional(peerID) == string)
|
||||
XCTAssertTrue(Optional(peerID) == Optional(string))
|
||||
|
||||
// PeerID != String
|
||||
XCTAssertTrue(peerID != badString)
|
||||
XCTAssertTrue(peerID != Optional(badString))
|
||||
XCTAssertTrue(Optional(peerID) != badString)
|
||||
XCTAssertTrue(Optional(peerID) != Optional(badString))
|
||||
|
||||
#expect(peerID != PeerID(str: "BBB"))
|
||||
#expect(peerID != Optional(PeerID(str: "BBB")))
|
||||
#expect(PeerID(str: "BBB") != peerID)
|
||||
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
|
||||
// String == PeerID
|
||||
XCTAssertTrue(string == peerID)
|
||||
XCTAssertTrue(Optional(string) == peerID)
|
||||
XCTAssertTrue(string == Optional(peerID))
|
||||
XCTAssertTrue(Optional(string) == Optional(peerID))
|
||||
|
||||
// String != PeerID
|
||||
XCTAssertTrue(badString != peerID)
|
||||
XCTAssertTrue(Optional(badString) != peerID)
|
||||
XCTAssertTrue(badString != Optional(peerID))
|
||||
XCTAssertTrue(Optional(badString) != Optional(peerID))
|
||||
|
||||
|
||||
// Make sure the regular PeerID <> PeerID is not broken
|
||||
XCTAssertTrue(peerID == PeerID(str: "aaa"))
|
||||
XCTAssertTrue(peerID == Optional(PeerID(str: "aaa")))
|
||||
XCTAssertTrue(PeerID(str: "aaa") == peerID)
|
||||
XCTAssertTrue(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
||||
|
||||
XCTAssertTrue(peerID != PeerID(str: "bbb"))
|
||||
XCTAssertTrue(peerID != Optional(PeerID(str: "bbb")))
|
||||
XCTAssertTrue(PeerID(str: "bbb") != peerID)
|
||||
XCTAssertTrue(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
||||
}
|
||||
|
||||
// MARK: - Computed properties
|
||||
|
||||
@Test func isEmpty_true_and_false() {
|
||||
#expect(PeerID(str: "").isEmpty)
|
||||
#expect(!PeerID(str: "abc").isEmpty)
|
||||
func test_isEmpty_true_and_false() {
|
||||
XCTAssertTrue(PeerID(str: "").isEmpty)
|
||||
XCTAssertFalse(PeerID(str: "abc").isEmpty)
|
||||
}
|
||||
|
||||
@Test func isGeoChat() {
|
||||
#expect(PeerID(str: "nostr:abcdef").isGeoChat)
|
||||
#expect(!PeerID(str: "nostr_abcdef").isGeoChat)
|
||||
func test_isGeoChat() {
|
||||
XCTAssertTrue(PeerID(str: "nostr:abcdef").isGeoChat)
|
||||
XCTAssertFalse(PeerID(str: "nostr_abcdef").isGeoChat) // different prefix
|
||||
}
|
||||
|
||||
@Test func isGeoDM() {
|
||||
#expect(PeerID(str: "nostr_abcdef").isGeoDM)
|
||||
#expect(!PeerID(str: "nostr:abcdef").isGeoDM)
|
||||
func test_isGeoDM() {
|
||||
XCTAssertTrue(PeerID(str: "nostr_abcdef").isGeoDM)
|
||||
XCTAssertFalse(PeerID(str: "nostr:abcdef").isGeoDM)
|
||||
}
|
||||
|
||||
@Test func toPercentEncoded() {
|
||||
func test_toPercentEncoded() {
|
||||
let peerID = PeerID(str: "name:some value/with spaces?")
|
||||
let encoded = peerID.toPercentEncoded()
|
||||
// spaces and ? should be percent-encoded in urlPathAllowed
|
||||
#expect(encoded == "name%3Asome%20value/with%20spaces%3F")
|
||||
XCTAssertEqual(encoded, "name%3Asome%20value/with%20spaces%3F")
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
@Test func accepts_short_hex_peer_id() {
|
||||
#expect(PeerID(str: "0011223344556677").isValid)
|
||||
#expect(PeerID(str: "aabbccddeeff0011").isValid)
|
||||
func test_accepts_short_hex_peer_id() {
|
||||
XCTAssertTrue(PeerID(str: "0011223344556677").isValid)
|
||||
XCTAssertTrue(PeerID(str: "aabbccddeeff0011").isValid)
|
||||
}
|
||||
|
||||
@Test func accepts_full_noise_key_hex() {
|
||||
func test_accepts_full_noise_key_hex() {
|
||||
let hex64 = String(repeating: "ab", count: 32) // 64 hex chars
|
||||
#expect(PeerID(str: hex64).isValid)
|
||||
XCTAssertTrue(PeerID(str: hex64).isValid)
|
||||
}
|
||||
|
||||
@Test func accepts_internal_alnum_dash_underscore() {
|
||||
#expect(PeerID(str: "peer_123-ABC").isValid)
|
||||
#expect(PeerID(str: "nostr_user_01").isValid)
|
||||
func test_accepts_internal_alnum_dash_underscore() {
|
||||
XCTAssertTrue(PeerID(str: "peer_123-ABC").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr_user_01").isValid)
|
||||
}
|
||||
|
||||
@Test func rejects_invalid_characters() {
|
||||
#expect(!PeerID(str: "peer!@#").isValid)
|
||||
#expect(!PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
|
||||
func test_rejects_invalid_characters() {
|
||||
XCTAssertFalse(PeerID(str: "peer!@#").isValid)
|
||||
XCTAssertFalse(PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
|
||||
}
|
||||
|
||||
@Test func rejects_too_long() {
|
||||
func test_rejects_too_long() {
|
||||
let tooLong = String(repeating: "a", count: 65)
|
||||
#expect(!PeerID(str: tooLong).isValid)
|
||||
XCTAssertFalse(PeerID(str: tooLong).isValid)
|
||||
}
|
||||
|
||||
@Test func isShort() {
|
||||
#expect(PeerID(str: hex16).isShort)
|
||||
#expect(!PeerID(str: "abcd").isShort) // wrong length
|
||||
func test_isShort() {
|
||||
XCTAssertTrue(PeerID(str: hex16).isShort)
|
||||
XCTAssertFalse(PeerID(str: "abcd").isShort) // wrong length
|
||||
}
|
||||
|
||||
@Test func isNoiseKeyHex_and_noiseKey() {
|
||||
func test_isNoiseKeyHex_and_noiseKey() {
|
||||
let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex
|
||||
let peerID = PeerID(str: hex64)
|
||||
#expect(peerID.isNoiseKeyHex)
|
||||
#expect(peerID.noiseKey != nil)
|
||||
XCTAssertTrue(peerID.isNoiseKeyHex)
|
||||
XCTAssertNotNil(peerID.noiseKey)
|
||||
|
||||
let prefixedPeerID = PeerID(str: "noise:" + hex64)
|
||||
#expect(prefixedPeerID.isNoiseKeyHex)
|
||||
#expect(prefixedPeerID.noiseKey != nil)
|
||||
XCTAssertTrue(prefixedPeerID.isNoiseKeyHex)
|
||||
XCTAssertNotNil(prefixedPeerID.noiseKey)
|
||||
|
||||
let bad = String(repeating: "z", count: 64) // invalid hex
|
||||
let badPeerID = PeerID(str: bad)
|
||||
#expect(!badPeerID.isNoiseKeyHex)
|
||||
#expect(badPeerID.noiseKey == nil)
|
||||
XCTAssertFalse(badPeerID.isNoiseKeyHex)
|
||||
XCTAssertNil(badPeerID.noiseKey)
|
||||
}
|
||||
|
||||
@Test func prefixes() {
|
||||
func test_prefixes() {
|
||||
let hex64 = String(repeating: "a", count: 64)
|
||||
#expect(PeerID(str: "noise:\(hex64)").isValid)
|
||||
#expect(PeerID(str: "nostr:\(hex64)").isValid)
|
||||
#expect(PeerID(str: "nostr_\(hex64)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "noise:\(hex64)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr:\(hex64)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr_\(hex64)").isValid)
|
||||
|
||||
let hex63 = String(repeating: "a", count: 63)
|
||||
#expect(PeerID(str: "noise:\(hex63)").isValid)
|
||||
#expect(PeerID(str: "nostr:\(hex63)").isValid)
|
||||
#expect(PeerID(str: "nostr_\(hex63)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "noise:\(hex63)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr:\(hex63)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr_\(hex63)").isValid)
|
||||
|
||||
let hex16 = String(repeating: "a", count: 16)
|
||||
#expect(PeerID(str: "noise:\(hex16)").isValid)
|
||||
#expect(PeerID(str: "nostr:\(hex16)").isValid)
|
||||
#expect(PeerID(str: "nostr_\(hex16)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "noise:\(hex16)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr:\(hex16)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr_\(hex16)").isValid)
|
||||
|
||||
let hex8 = String(repeating: "a", count: 8)
|
||||
#expect(PeerID(str: "noise:\(hex8)").isValid)
|
||||
#expect(PeerID(str: "nostr:\(hex8)").isValid)
|
||||
#expect(PeerID(str: "nostr_\(hex8)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "noise:\(hex8)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr:\(hex8)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "nostr_\(hex8)").isValid)
|
||||
|
||||
let mesh = "mesh:abcdefg"
|
||||
#expect(PeerID(str: "name:\(mesh)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "name:\(mesh)").isValid)
|
||||
|
||||
let name = "name:some_name"
|
||||
#expect(PeerID(str: "name:\(name)").isValid)
|
||||
XCTAssertTrue(PeerID(str: "name:\(name)").isValid)
|
||||
|
||||
let badName = "name:bad:name"
|
||||
#expect(!PeerID(str: "name:\(badName)").isValid)
|
||||
XCTAssertFalse(PeerID(str: "name:\(badName)").isValid)
|
||||
|
||||
// Too long
|
||||
let hex65 = String(repeating: "a", count: 65)
|
||||
#expect(!PeerID(str: "noise:\(hex65)").isValid)
|
||||
#expect(!PeerID(str: "nostr:\(hex65)").isValid)
|
||||
#expect(!PeerID(str: "nostr_\(hex65)").isValid)
|
||||
XCTAssertFalse(PeerID(str: "noise:\(hex65)").isValid)
|
||||
XCTAssertFalse(PeerID(str: "nostr:\(hex65)").isValid)
|
||||
XCTAssertFalse(PeerID(str: "nostr_\(hex65)").isValid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,6 @@ let package = Package(
|
||||
.target(
|
||||
name: "BitLogger",
|
||||
path: "Sources"
|
||||
),
|
||||
.testTarget(
|
||||
name: "BitLoggerTests",
|
||||
dependencies: ["BitLogger"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if canImport(os.log)
|
||||
import os.log
|
||||
#endif
|
||||
|
||||
public extension OSLog {
|
||||
private static let subsystem = "chat.bitchat"
|
||||
|
||||
|
||||
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
||||
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
|
||||
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
|
||||
|
||||
@@ -7,53 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if canImport(os.log)
|
||||
import os.log
|
||||
#else
|
||||
public struct OSLog {
|
||||
public let subsystem: String
|
||||
public let category: String
|
||||
|
||||
public init(subsystem: String, category: String) {
|
||||
self.subsystem = subsystem
|
||||
self.category = category
|
||||
}
|
||||
}
|
||||
|
||||
public struct OSLogType: CustomStringConvertible {
|
||||
private let label: String
|
||||
|
||||
private init(_ label: String) {
|
||||
self.label = label
|
||||
}
|
||||
|
||||
public var description: String { label }
|
||||
|
||||
public static let debug = OSLogType("debug")
|
||||
public static let info = OSLogType("info")
|
||||
public static let `default` = OSLogType("default")
|
||||
public static let error = OSLogType("error")
|
||||
public static let fault = OSLogType("fault")
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
let secureLoggerFallbackFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@usableFromInline
|
||||
func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {
|
||||
let rawFormat = String(describing: message)
|
||||
let format = rawFormat
|
||||
.replacingOccurrences(of: "%{public}@", with: "%@")
|
||||
.replacingOccurrences(of: "%{private}@", with: "%@")
|
||||
let formatted = String(format: format, arguments: args)
|
||||
let timestamp = secureLoggerFallbackFormatter.string(from: Date())
|
||||
print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)")
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Centralized security-aware logging framework
|
||||
/// Provides safe logging that filters sensitive data and security events
|
||||
@@ -68,6 +22,22 @@ public final class SecureLogger {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Cached Regex Patterns
|
||||
|
||||
private static let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
||||
private static let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
||||
private static let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||
private static let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
||||
|
||||
// MARK: - Sanitization Cache
|
||||
|
||||
private static let sanitizationCache: NSCache<NSString, NSString> = {
|
||||
let cache = NSCache<NSString, NSString>()
|
||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||
return cache
|
||||
}()
|
||||
private static let cacheQueue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
||||
|
||||
// MARK: - Log Levels
|
||||
|
||||
enum LogLevel {
|
||||
@@ -145,8 +115,8 @@ public extension SecureLogger {
|
||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||
file: String = #file, line: Int = #line, function: String = #function) {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = context().sanitized()
|
||||
let errorDesc = error.localizedDescription.sanitized()
|
||||
let sanitized = sanitize(context())
|
||||
let errorDesc = sanitize(error.localizedDescription)
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
@@ -170,15 +140,15 @@ public extension SecureLogger {
|
||||
var message: String {
|
||||
switch self {
|
||||
case .handshakeStarted(let peerID):
|
||||
return "Handshake started with peer: \(peerID.sanitized())"
|
||||
return "Handshake started with peer: \(sanitize(peerID))"
|
||||
case .handshakeCompleted(let peerID):
|
||||
return "Handshake completed with peer: \(peerID.sanitized())"
|
||||
return "Handshake completed with peer: \(sanitize(peerID))"
|
||||
case .handshakeFailed(let peerID, let error):
|
||||
return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)"
|
||||
return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)"
|
||||
case .sessionExpired(let peerID):
|
||||
return "Session expired for peer: \(peerID.sanitized())"
|
||||
return "Session expired for peer: \(sanitize(peerID))"
|
||||
case .authenticationFailed(let peerID):
|
||||
return "Authentication failed for peer: \(peerID.sanitized())"
|
||||
return "Authentication failed for peer: \(sanitize(peerID))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +203,7 @@ private extension SecureLogger {
|
||||
file: String, line: Int, function: String) {
|
||||
guard shouldLog(level) else { return }
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = "\(location) \(message())".sanitized()
|
||||
let sanitized = sanitize("\(location) \(message())")
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||
@@ -266,6 +236,58 @@ private extension SecureLogger {
|
||||
let timestamp = timestampFormatter.string(from: Date())
|
||||
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
||||
}
|
||||
|
||||
/// Sanitize strings to remove potentially sensitive data
|
||||
static func sanitize(_ input: String) -> String {
|
||||
let key = input as NSString
|
||||
|
||||
// Check cache first
|
||||
var cachedValue: String?
|
||||
cacheQueue.sync {
|
||||
cachedValue = sanitizationCache.object(forKey: key) as String?
|
||||
}
|
||||
|
||||
if let cached = cachedValue {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Perform sanitization
|
||||
var sanitized = input
|
||||
|
||||
// Remove full fingerprints (keep first 8 chars for debugging)
|
||||
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
||||
let fingerprint = String(match.output)
|
||||
return String(fingerprint.prefix(8)) + "..."
|
||||
}
|
||||
|
||||
// Remove base64 encoded data that might be keys
|
||||
sanitized = sanitized.replacing(base64Pattern) { _ in
|
||||
"<base64-data>"
|
||||
}
|
||||
|
||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||
"password: <redacted>"
|
||||
}
|
||||
|
||||
// Truncate peer IDs to first 8 characters
|
||||
sanitized = sanitized.replacing(peerIDPattern) { match in
|
||||
"peerID: \(match.1)..."
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cacheQueue.sync {
|
||||
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/// Sanitize individual values
|
||||
static func sanitize<T>(_ value: T) -> String {
|
||||
let stringValue = String(describing: value)
|
||||
return sanitize(stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration Helper
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
//
|
||||
// String+Sanitization.swift
|
||||
// BitLogger
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Sanitize strings to remove potentially sensitive data
|
||||
func sanitized() -> String {
|
||||
let key = self as NSString
|
||||
|
||||
// Check cache first
|
||||
if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) {
|
||||
return cached as String
|
||||
}
|
||||
|
||||
var sanitized = self
|
||||
|
||||
// Remove full fingerprints (keep first 8 chars for debugging)
|
||||
let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
||||
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
||||
let fingerprint = String(match.output)
|
||||
return String(fingerprint.prefix(8)) + "..."
|
||||
}
|
||||
|
||||
// Remove base64 encoded data that might be keys
|
||||
let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
||||
sanitized = sanitized.replacing(base64Pattern) { _ in
|
||||
"<base64-data>"
|
||||
}
|
||||
|
||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||
let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||
"password: <redacted>"
|
||||
}
|
||||
|
||||
// Truncate peer IDs to first 8 characters
|
||||
let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
||||
sanitized = sanitized.replacing(peerIDPattern) { match in
|
||||
"peerID: \(match.1)..."
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
Self.queue.sync {
|
||||
Self.cache.setObject(sanitized as NSString, forKey: key)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache Helpers
|
||||
|
||||
private extension String {
|
||||
static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
||||
|
||||
static let cache: NSCache<NSString, NSString> = {
|
||||
let cache = NSCache<NSString, NSString>()
|
||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||
return cache
|
||||
}()
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//
|
||||
// StringSanitizationTests.swift
|
||||
// BitLogger
|
||||
//
|
||||
// Created by Islam on 19/10/2025.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import BitLogger
|
||||
|
||||
struct StringSanitizationTests {
|
||||
|
||||
@Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis")
|
||||
func fingerprintTruncation() async throws {
|
||||
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
#expect(fingerprint.count == 64)
|
||||
|
||||
let input = "fingerprint=\(fingerprint)"
|
||||
let output = input.sanitized()
|
||||
|
||||
#expect(output.contains("fingerprint=01234567..."))
|
||||
// Ensure no full fingerprint remains
|
||||
#expect(output.contains(fingerprint) == false)
|
||||
}
|
||||
|
||||
@Test("Multiple fingerprints in a string are all truncated")
|
||||
func multipleFingerprintTruncation() async throws {
|
||||
let fp1 = String(repeating: "a", count: 64)
|
||||
let fp2 = String(repeating: "b", count: 64)
|
||||
let input = "fp1=\(fp1) fp2=\(fp2)"
|
||||
let output = input.sanitized()
|
||||
#expect(output.contains("fp1=aaaaaaaa..."))
|
||||
#expect(output.contains("fp2=bbbbbbbb..."))
|
||||
#expect(output.contains(fp1) == false)
|
||||
#expect(output.contains(fp2) == false)
|
||||
}
|
||||
|
||||
@Test("Base64-like long data is replaced with <base64-data>")
|
||||
func base64Replacement() async throws {
|
||||
// 44+ chars of base64 characters
|
||||
let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE="
|
||||
let input = "payload=\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "payload=<base64-data>")
|
||||
}
|
||||
|
||||
@Test("Base64-like without padding is replaced with <base64-data>")
|
||||
func base64NoPaddingReplacement() async throws {
|
||||
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
#expect(base64ish.count >= 40)
|
||||
let input = "b64:\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "b64:<base64-data>")
|
||||
}
|
||||
|
||||
@Test("Short base64-like strings (below threshold) are not replaced")
|
||||
func shortBase64NotReplaced() async throws {
|
||||
let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars
|
||||
let input = "payload=\(short)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == input)
|
||||
}
|
||||
|
||||
@Test("Password redaction for key:value formats", arguments: [
|
||||
"password: secret123",
|
||||
"password=secret123",
|
||||
"password = secret123",
|
||||
"password: 'secret123'",
|
||||
"password:\"secret123\"",
|
||||
"password='secret123'"
|
||||
])
|
||||
func passwordRedactionKeyValue(password: String) async throws {
|
||||
#expect(password.sanitized() == "password: <redacted>")
|
||||
}
|
||||
|
||||
@Test("Password redaction inside wider messages")
|
||||
func passwordRedactionInContext() async throws {
|
||||
let input = "user=john password: 'p@ssW0rd' attempt=1"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "user=john password: <redacted> attempt=1")
|
||||
}
|
||||
|
||||
@Test("PeerID is truncated to first 8 chars followed by ellipsis")
|
||||
func peerIDTruncation() async throws {
|
||||
let peer = "ABCDEF12GHIJKL34"
|
||||
let input = "peerID: \(peer)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "peerID: ABCDEF12...")
|
||||
}
|
||||
|
||||
@Test("PeerID not truncated when exactly 8 chars")
|
||||
func peerIDExactlyEightNotTruncated() async throws {
|
||||
let peer = "ABCDEF12"
|
||||
let input = "peerID: \(peer)"
|
||||
let output = input.sanitized()
|
||||
// Pattern only matches when there are more than 8 trailing chars, so unchanged
|
||||
#expect(output == input)
|
||||
}
|
||||
|
||||
@Test("Non-matching content remains unchanged")
|
||||
func nonMatchingUnchanged() async throws {
|
||||
let input = "Hello world 123 - nothing sensitive here."
|
||||
let output = input.sanitized()
|
||||
#expect(output == input)
|
||||
}
|
||||
|
||||
@Test("Idempotency: sanitizing twice yields same result")
|
||||
func idempotentSanitization() async throws {
|
||||
let input = """
|
||||
fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
|
||||
password: "superSecret" \
|
||||
peerID: ZYXWVUT987654321 \
|
||||
payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
|
||||
"""
|
||||
let once = input.sanitized()
|
||||
let twice = once.sanitized()
|
||||
#expect(once == twice)
|
||||
}
|
||||
|
||||
@Test("Mixed content: all rules apply in a single string")
|
||||
func mixedContent() async throws {
|
||||
let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
let peer = "PEERID01EXTRA"
|
||||
let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output.contains("fp=fedcba98..."))
|
||||
#expect(output.contains("password: <redacted>"))
|
||||
#expect(output.contains("peerID: PEERID01..."))
|
||||
#expect(output.contains("data=<base64-data>"))
|
||||
#expect(output.contains(fingerprint) == false)
|
||||
#expect(output.contains(base64ish) == false)
|
||||
}
|
||||
|
||||
@Test("Cache returns consistent result for repeated inputs")
|
||||
func cacheHitConsistency() async throws {
|
||||
let input = "password: hunter2"
|
||||
let first = input.sanitized()
|
||||
let second = input.sanitized()
|
||||
#expect(first == "password: <redacted>")
|
||||
#expect(first == second)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,11 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
#endif
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
#if !canImport(Network)
|
||||
private final class NWPathMonitor {
|
||||
var pathUpdateHandler: ((Any) -> Void)?
|
||||
|
||||
func start(queue: DispatchQueue) {
|
||||
// Path monitoring is unavailable on this platform; nothing to do.
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Declare C entrypoint for Tor when statically linked from an xcframework.
|
||||
@_silgen_name("tor_main")
|
||||
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
||||
|
||||
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
|
||||
@_silgen_name("tor_host_start")
|
||||
@@ -298,6 +286,150 @@ public final class TorManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dynamic loader path (no Swift module required)
|
||||
|
||||
/// Attempt to locate an embedded tor framework binary and launch Tor via `tor_run_main`.
|
||||
/// Returns true if the attempt started and port probing was scheduled.
|
||||
private func startTorViaDlopen() -> Bool {
|
||||
guard let fwURL = frameworkBinaryURL() else {
|
||||
SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
|
||||
return false
|
||||
}
|
||||
|
||||
// Load the library
|
||||
let mode = RTLD_NOW | RTLD_LOCAL
|
||||
SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
|
||||
guard let handle = dlopen(fwURL.path, mode) else {
|
||||
let err = String(cString: dlerror())
|
||||
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
|
||||
self.isStarting = false
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve tor_main(argc, argv)
|
||||
typealias TorMainType = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
||||
guard let sym = dlsym(handle, "tor_main") else {
|
||||
// Keep handle open but report error
|
||||
let err = String(cString: dlerror())
|
||||
self.lastError = NSError(domain: "TorManager", code: -11, userInfo: [NSLocalizedDescriptionKey: "dlsym tor_main failed: \(err)"])
|
||||
self.isStarting = false
|
||||
return false
|
||||
}
|
||||
let torMain = unsafeBitCast(sym, to: TorMainType.self)
|
||||
self._dlHandle = handle
|
||||
|
||||
// Prepare args: tor -f <torrc>
|
||||
var argv: [String] = ["tor"]
|
||||
if let torrc = torrcURL()?.path {
|
||||
argv.append(contentsOf: ["-f", torrc])
|
||||
}
|
||||
// Run Tor on a background thread to avoid blocking the main actor
|
||||
SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
|
||||
let argc = Int32(argv.count)
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
// Build stable C argv in this thread
|
||||
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
||||
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
|
||||
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
|
||||
cArgv[cStrings.count] = nil
|
||||
|
||||
_ = torMain(argc, cArgv)
|
||||
|
||||
// Free args after exit (Tor usually never returns)
|
||||
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
|
||||
cArgv.deallocate()
|
||||
}
|
||||
|
||||
// Start control-port monitor and probe readiness asynchronously
|
||||
startControlMonitorIfNeeded()
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||
await MainActor.run {
|
||||
self.socksReady = ready
|
||||
if !ready {
|
||||
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
|
||||
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
||||
} else {
|
||||
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
||||
}
|
||||
// isStarting will be cleared when bootstrap reaches 100%
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private var _dlHandle: UnsafeMutableRawPointer?
|
||||
|
||||
private func frameworkBinaryURL() -> URL? {
|
||||
// Try common embedded locations for the framework binary name
|
||||
let candidates = [
|
||||
"tor-nolzma.framework/tor-nolzma",
|
||||
"Tor.framework/Tor",
|
||||
]
|
||||
if let base = Bundle.main.privateFrameworksURL {
|
||||
for rel in candidates {
|
||||
let url = base.appendingPathComponent(rel)
|
||||
if FileManager.default.fileExists(atPath: url.path) { return url }
|
||||
}
|
||||
}
|
||||
// For macOS apps, also try Contents/Frameworks explicitly
|
||||
#if os(macOS)
|
||||
if let appURL = Bundle.main.bundleURL as URL?,
|
||||
let frameworksURL = Optional(appURL.appendingPathComponent("Contents/Frameworks", isDirectory: true)) {
|
||||
for rel in candidates {
|
||||
let url = frameworksURL.appendingPathComponent(rel)
|
||||
if FileManager.default.fileExists(atPath: url.path) { return url }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Static-link path (no module import)
|
||||
private func startTorViaLinkedSymbol() -> Bool {
|
||||
// Attempt to start tor_run_main directly (statically linked). If the
|
||||
// symbol is not present at link-time, builds will fail — which is
|
||||
// expected when the xcframework is absent.
|
||||
var argv: [String] = ["tor"]
|
||||
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
|
||||
|
||||
SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
|
||||
let argc = Int32(argv.count)
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
// Build stable C argv in this thread
|
||||
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
||||
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
|
||||
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
|
||||
cArgv[cStrings.count] = nil
|
||||
|
||||
_ = tor_main_c(argc, cArgv)
|
||||
|
||||
// If tor_main ever returns, free memory
|
||||
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
|
||||
cArgv.deallocate()
|
||||
}
|
||||
|
||||
// Start control monitor early
|
||||
startControlMonitorIfNeeded()
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
||||
await MainActor.run {
|
||||
self.socksReady = ready
|
||||
if ready {
|
||||
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
||||
} else {
|
||||
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
||||
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
||||
}
|
||||
// isStarting will be cleared when bootstrap reaches 100%
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - ControlPort monitoring (bootstrap progress)
|
||||
private func startControlMonitorIfNeeded() {
|
||||
guard !controlMonitorStarted else { return }
|
||||
@@ -308,6 +440,10 @@ public final class TorManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func controlMonitorLoop() async {}
|
||||
|
||||
private func tryControlSessionOnce() async -> Bool { false }
|
||||
|
||||
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
||||
private func bootstrapPollLoop() async {
|
||||
let deadline = Date().addingTimeInterval(75)
|
||||
|
||||
Reference in New Issue
Block a user