diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index c6773461..693365ca 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -104,12 +104,10 @@ final class LRUDeduplicationCache { enum ContentNormalizer { /// Regex to simplify HTTP URLs by stripping query strings and fragments - private static let simplifyHTTPURL: NSRegularExpression = { - try! NSRegularExpression( - pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", - options: [.caseInsensitive] - ) - }() + private static let simplifyHTTPURL = SafeRegex.compile( + "https?://[^\\s?#]+(?:[?#][^\\s]*)?", + options: [.caseInsensitive] + ) /// Normalizes content for deduplication comparison. /// - Parameters: diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 43adcb19..8f77bfa5 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -39,37 +39,23 @@ final class MessageFormattingEngine { /// Precompiled regex patterns for message content parsing enum Patterns { - static let hashtag: NSRegularExpression = { - try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: []) - }() + static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)") - static let mention: NSRegularExpression = { - try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: []) - }() + static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") - static let cashu: NSRegularExpression = { - try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) - }() + static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b") - static let bolt11: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) - }() + static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b") - static let lnurl: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) - }() + static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b") - static let lightningScheme: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) - }() + static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+") static let linkDetector: NSDataDetector? = { try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }() - static let quickCashuPresence: NSRegularExpression = { - try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) - }() + static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b") } // MARK: - Match Types diff --git a/bitchat/Utils/SafeRegex.swift b/bitchat/Utils/SafeRegex.swift new file mode 100644 index 00000000..125f2f80 --- /dev/null +++ b/bitchat/Utils/SafeRegex.swift @@ -0,0 +1,36 @@ +// +// SafeRegex.swift +// bitchat +// +// Non-trapping construction for the app's compiled-in regex patterns. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +enum SafeRegex { + /// Compiles a bundled pattern. On failure it logs and returns a regex + /// that can never match, so a bad pattern degrades that one feature + /// instead of crashing at startup. + static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern, options: options) + } catch { + SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session) + return neverMatching + } + } + + /// `(?!)` — an empty negative lookahead — always compiles and can never match. + private static let neverMatching: NSRegularExpression = { + if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) { + return regex + } + // Unreachable: "(?!)" is a valid ICU pattern. The inherited plain + // initializer (empty pattern) is the least-bad non-trapping fallback + // if ICU itself were ever broken. + return NSRegularExpression() + }() +} diff --git a/bitchatTests/Services/SafeRegexTests.swift b/bitchatTests/Services/SafeRegexTests.swift new file mode 100644 index 00000000..fe92a6b8 --- /dev/null +++ b/bitchatTests/Services/SafeRegexTests.swift @@ -0,0 +1,64 @@ +// +// SafeRegexTests.swift +// bitchatTests +// +// SafeRegex must never trap: valid patterns compile normally, invalid ones +// degrade to a regex that matches nothing. The production-pattern test keeps +// the compile-time guarantee try! used to provide - a typo in any bundled +// pattern fails here instead of crashing the app at startup. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct SafeRegexTests { + + private func matchCount(_ regex: NSRegularExpression, _ text: String) -> Int { + regex.numberOfMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + } + + @Test + func validPatternCompilesAndMatches() { + let regex = SafeRegex.compile("#([a-zA-Z0-9_]+)") + #expect(matchCount(regex, "tag #bitchat here") == 1) + } + + @Test + func invalidPatternDegradesToNeverMatching() { + let regex = SafeRegex.compile("(unclosed") + #expect(matchCount(regex, "(unclosed anything") == 0) + #expect(matchCount(regex, "") == 0) + } + + @Test + func productionPatternsCompileAndMatchTheirTargets() { + // A pattern that failed to compile would have degraded to + // never-matching, so each positive match proves the literal compiled. + #expect(matchCount(MessageFormattingEngine.Patterns.hashtag, "see #mesh") == 1) + #expect(matchCount(MessageFormattingEngine.Patterns.mention, "hi @alice#ab12") == 1) + + let cashuToken = "cashuA" + String(repeating: "x", count: 45) + #expect(matchCount(MessageFormattingEngine.Patterns.cashu, cashuToken) == 1) + #expect(matchCount(MessageFormattingEngine.Patterns.quickCashuPresence, cashuToken) == 1) + + let bolt11 = "lnbc1" + String(repeating: "q", count: 55) + #expect(matchCount(MessageFormattingEngine.Patterns.bolt11, bolt11) == 1) + + let lnurl = "lnurl1" + String(repeating: "q", count: 25) + #expect(matchCount(MessageFormattingEngine.Patterns.lnurl, lnurl) == 1) + + #expect(matchCount(MessageFormattingEngine.Patterns.lightningScheme, "pay lightning:abc123") == 1) + } + + @Test + func contentNormalizerStillSimplifiesURLs() { + // Exercises ContentNormalizer's regex through its public entry point: + // same URL with different query strings must normalize identically. + let a = ContentNormalizer.normalizedKey("check https://example.com/page?q=1") + let b = ContentNormalizer.normalizedKey("check https://example.com/page?q=2") + #expect(a == b) + } +}